Files
prautoblogger/includes/core/class-image-template-filler.php
peptiderepo 5eb9e598b3 Commit 1: editorial image prompt + style-template setting
Pivot the image pipeline from the single-panel comic style to a text-free
editorial scientific illustration, and replace the Style Suffix setting with
an editable Style Template setting. Commit 1 of the in-plugin editorial image
pipeline (no composer yet; pipeline otherwise unchanged).

- New constant PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE (brief §7 editorial
  template with the single {{ topic_summary }} token).
- New setting prautoblogger_image_style_template (textarea) replaces the
  Style Suffix field; single-token validation on save (brief A5); old
  prautoblogger_image_style_suffix mirrored to _deprecated for one cycle and
  not deleted (one-time migration prautoblogger_migrated_style_template_v0160).
- REWRITER_SYSTEM_PROMPT now emits a 1-2 sentence editorial topic/mechanism
  summary as the SCENE (no comic gag), keeping the CAPTION line for the
  unchanged HTML-caption-below-image path; scene/caption parsing contract
  preserved.
- New PRAutoBlogger_Image_Template_Filler substitutes the scene into the
  template's {{ topic_summary }} slot across all three entry points; strips
  control chars + clamps length (A5); degrades safely on missing token or
  empty summary so a blank/token-only prompt is never sent to the provider
  (A5/A6).
- Version bump 0.15.1 -> 0.16.0 + CHANGELOG + ARCHITECTURE.md updates.

Agent-Session: image-editorial-commit1
2026-05-30 00:04:26 +00:00

186 lines
6.7 KiB
PHP

<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Fills the admin-editable editorial image-prompt template with the
* rewriter-produced topic summary, replacing the old "scene + style suffix"
* concatenation.
*
* The template (option `prautoblogger_image_style_template`, default
* PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE) contains exactly one
* `{{ topic_summary }}` token. This class:
* - resolves the active template (admin override wins; blank → default),
* - sanitises and length-clamps the topic summary (brief A5),
* - substitutes the token, and
* - degrades safely (brief A5/A6) so a blank or token-only prompt is never
* emitted to the image provider.
*
* Who calls it: PRAutoBlogger_Image_Prompt_Builder (static call from each of
* build_article_prompt / build_source_prompt / build_fallback_prompt).
* Dependencies: PRAutoBlogger_Logger (diagnostics only).
*
* @see core/class-image-prompt-builder.php — Sole caller.
* @see ARCHITECTURE.md — Image generation data flow.
*/
class PRAutoBlogger_Image_Template_Filler {
/** The single substitution token the template must contain. */
public const TOKEN = '{{ topic_summary }}';
/**
* Max characters retained from the rewriter topic summary before it is
* substituted into the template. Keeps the final provider prompt bounded
* even if the rewriter ignores its length guidance.
*/
public const MAX_SUMMARY_CHARS = 320;
/**
* Build the final image-generation prompt from a rewriter scene.
*
* @param string $scene Raw topic/mechanism summary from the rewriter (the
* "scene" half of the scene/caption contract).
* @return string Provider-ready prompt; never empty.
*/
public static function fill( string $scene ): string {
$template = self::resolve_template();
$summary = self::sanitize_summary( $scene );
// Brief A6: never ship a blank or token-only prompt. If the rewriter
// produced nothing usable, return the template with the token removed
// only as a last resort — but the caller guards this by passing a
// rule-based fallback scene, so an empty summary here is unexpected.
if ( '' === $summary ) {
PRAutoBlogger_Logger::instance()->warning(
'Editorial prompt fill received an empty topic summary; emitting style-only prompt.',
'image_template_filler'
);
return self::strip_token( $template );
}
$token_count = substr_count( $template, self::TOKEN );
if ( 1 === $token_count ) {
return trim( str_replace( self::TOKEN, $summary, $template ) );
}
// Brief A5: admin removed or duplicated the token. Fall back to
// appending the summary to the style text and log a warning rather
// than shipping a malformed prompt.
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'Image style template has %d "%s" tokens (expected 1); appending summary instead of substituting.',
$token_count,
self::TOKEN
),
'image_template_filler'
);
$base = self::strip_token( $template );
return trim( $base . ' ' . $summary );
}
/**
* Sanitise and validate the admin-submitted style template on save.
*
* Multi-line, so sanitised with sanitize_textarea_field() (not
* sanitize_text_field(), which would strip newlines). Per brief A5 the
* template must contain exactly one TOKEN; otherwise the previous value is
* kept and a settings error is surfaced. A blank submission falls back to
* the editorial default so image generation can never be bricked.
*
* @param string $value Submitted template.
* @return string Sanitised, validated template.
*/
public static function sanitize_for_save( string $value ): string {
$clean = sanitize_textarea_field( $value );
if ( '' === trim( $clean ) ) {
return PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE;
}
$token_count = substr_count( $clean, self::TOKEN );
if ( 1 !== $token_count ) {
add_settings_error(
'prautoblogger_image_style_template',
'prautoblogger_image_style_template_token',
sprintf(
/* translators: 1: required token, 2: number of tokens found. */
esc_html__( 'The Style Template must contain exactly one %1$s token (found %2$d). Keeping the previous value.', 'prautoblogger' ),
esc_html( self::TOKEN ),
(int) $token_count
)
);
return (string) get_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE );
}
return $clean;
}
/**
* Resolve the active template: admin override wins; blank → default.
*
* @return string Non-empty template string.
*/
private static function resolve_template(): string {
$override = (string) get_option( 'prautoblogger_image_style_template', '' );
if ( '' !== trim( $override ) ) {
return $override;
}
return PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE;
}
/**
* Strip control characters and clamp length of the rewriter summary
* (brief A5). Whitespace runs (including newlines) are collapsed to single
* spaces so the summary sits cleanly inside the template.
*
* @param string $scene Raw rewriter scene.
* @return string Sanitised, length-clamped summary (possibly empty).
*/
private static function sanitize_summary( string $scene ): string {
// Remove control characters (C0/C1) except via whitespace collapse below.
$clean = preg_replace( '/[\x00-\x1F\x7F]+/u', ' ', $scene );
if ( null === $clean ) {
// preg_replace can return null on bad UTF-8; degrade conservatively.
$clean = preg_replace( '/[\x00-\x1F\x7F]+/', ' ', $scene );
$clean = is_string( $clean ) ? $clean : '';
}
// Collapse whitespace runs to single spaces.
$collapsed = preg_replace( '/\s+/u', ' ', $clean );
$clean = is_string( $collapsed ) ? $collapsed : $clean;
$clean = trim( $clean );
if ( '' === $clean ) {
return '';
}
// Clamp length (multibyte-aware where available).
if ( function_exists( 'mb_strlen' ) ) {
if ( mb_strlen( $clean ) > self::MAX_SUMMARY_CHARS ) {
$clean = rtrim( mb_substr( $clean, 0, self::MAX_SUMMARY_CHARS ) ) . '...';
}
} elseif ( strlen( $clean ) > self::MAX_SUMMARY_CHARS ) {
$clean = rtrim( substr( $clean, 0, self::MAX_SUMMARY_CHARS ) ) . '...';
}
return $clean;
}
/**
* Remove every occurrence of the token from a template, tidying the
* leftover whitespace so the residual style text is still well-formed.
*
* @param string $template Template possibly containing the token.
* @return string Token-free, trimmed template.
*/
private static function strip_token( string $template ): string {
$stripped = str_replace( self::TOKEN, '', $template );
$stripped = preg_replace( '/[ \t]+/', ' ', $stripped );
$stripped = is_string( $stripped ) ? $stripped : $template;
return trim( $stripped );
}
}