Dev.to AI ๐Ÿค– Ai ๐Ÿ‘ 0 ๐Ÿ“– 6 min read

[6/10]Page-Templated PDF Generation: From YAML Slots to BentoUI Widgets

Series: Building a Modular Assessment Engine (6/10) The report module generates personalized PDF reports. Each user who completes an assessment gets a unique report with their scores, dimension interpretations, and visu

[6/10]Page-Templated PDF Generation: From YAML Slots to BentoUI Widgets

Series: Building a Modular Assessment Engine (6/10)

The report module generates personalized PDF reports. Each user who completes an assessment gets a unique report with their scores, dimension interpretations, and visualizations. This post covers the page template system, the widget grid, and the variable pipeline that makes personalization work.

The Template-First Approach

Reports don't start from blank pages. They start from page templates โ€” predefined page structures that the engine assembles based on the assessment's dimension count and theme.

The plan agent declares two values:

DimN=3
theme=Noir

The engine computes the page template list automatically:

cover-noir          โ†’ Cover page (Noir theme)
summary-3dim        โ†’ Summary page (radar chart for 3 dimensions)
detail-dim1         โ†’ Dimension 1 detail page
detail-dim2         โ†’ Dimension 2 detail page
detail-dim3         โ†’ Dimension 3 detail page
total-assessment    โ†’ Total score conclusion page

No AI involvement in this step. The engine reads the dimension count from the in-memory scale data and picks templates from a registry. This was a hard-won lesson โ€” earlier versions let the AI choose template IDs, and it would invent non-existent templates or pick wrong page counts.

The Runtime Fallback

What happens when the plan agent forgets to declare dimension count? The engine recovers:

// In AssessAgent.skillGenerateStream()
if ("report".equals(skillName) && ObjectUtil.isEmpty(task.getPageTemplates())) {
    Factor _factor = session.getFlower().getModel().getFactor();
    if (_factor != null && !ObjectUtil.isEmpty(_factor.getScales())) {
        int _dimCount = _factor.getScales().size();
        List<String> _recovered = buildPageTemplates(_dimCount, task, planType);
        task.setPageTemplates(_recovered);
        FLog.w(TAG, "pageTemplates recovered from scale data, dimCount=" + _dimCount);
    }
}

By the time the report skill runs, the scale skill has already created dimensions in memory. The engine counts them and builds the template list. The AI's declaration is a hint, not a dependency.

The Widget Grid System

Each page is a 68ร—48 grid (0-indexed). Widgets are placed by coordinates:

assess report widget add \
  --page cover_1 \
  --type text \
  --x 2 --y 3 --w 44 --h 6 \
  --value "<h1>{{DisplayName}}'s Assessment Report</h1>"

Constraints:

  • x + w โ‰ค 48 (cannot exceed grid width)
  • y + h โ‰ค 68 (cannot exceed grid height)
  • No overlapping widgets on the same page

The grid system was chosen over free-form positioning because:

  1. It's predictable โ€” the AI can reason about layout mathematically
  2. It's responsive โ€” the same grid produces valid layouts at different PDF sizes
  3. It prevents overlaps โ€” a common bug with absolute positioning

BentoUI Style Parameters

Widgets support a constrained set of style parameters:

assess report widget update \
  --page detail_1 --id score_card \
  --backgroundColor "#1a1a2e" \
  --fontColor "#E2E8F0" \
  --roundCorner 16 \
  --enableBorder true \
  --borderColor "#33334c" \
  --borderSize 1 \
  --paddingSize 16 \
  --bold true \
  --alignX left

The parameter whitelist is strict. The AI can't invent --shadow or --gradient โ€” they don't exist. This prevents the AI from generating CSS that the PDF renderer (iText) doesn't support.

The Variable System

Reports are personalized through template variables:

assess report widget update \
  --page summary_1 --id greeting \
  --value "<p>Hello, {{DisplayName}}!</p>"

Variables are wrapped in {{double curly braces}}. The engine replaces them with actual user data at render time:

Variable Replaced With
{{DisplayName}} User's name from the form
{{DateTime}} Report generation timestamp
{{DimensionName}} Current dimension's name
{{DimensionScore}} User's score on current dimension
{{DimensionTotal}} Maximum possible score for dimension
{{ScaleScore}} Total score across all dimensions
{{PersonalScore}} User's personal total
{{TotalScore}} Maximum possible total
{{ScoreRate}} Score percentage

The Bare Variable Bug

The most common report bug: variables without braces.

# Wrong โ€” the engine treats "DisplayName" as literal text
--value "<p>Hello, DisplayName!</p>"

# The user sees "Hello, DisplayName!" instead of "Hello, John!"

This happens because variable names look like regular English words. The AI writes DisplayName when it means {{DisplayName}}. The bug is invisible during generation (the command succeeds) and only surfaces when a user reads their report.

The fix: a P0 validation rule that scans all --value and --logic parameters for bare variable names and rejects them. The SKILL.md includes a checklist of common mistakes:

โŒ "Hello, DisplayName" โ†’ โœ… "Hello, {{DisplayName}}"
โŒ "Score: DimensionScore/DimensionTotal" โ†’ โœ… "Score: {{DimensionScore}}/{{DimensionTotal}}"
โŒ "<p>DateTime</p>" โ†’ โœ… "<p>{{DateTime}}</p>"

The Logic System: Score-Tiered Content

Different score tiers need different interpretations. A user with severe burnout needs different text than one with mild burnout. This is handled through --logic parameters:

assess report widget update \
  --page detail_1 --id burnout_interpretation \
  --type scale-dimension \
  --scaleId burnout \
  --enableOwn \
  --value "<p>Your burnout level: {{DimensionScore}}/{{DimensionTotal}}</p>" \
  --logic "19-999|<p>Your burnout is low. You're managing well โ€” keep up the practices that work.</p>" \
  --logic "11-18|<p>You show moderate signs of burnout. Consider reviewing your workload and recovery routines.</p>" \
  --logic "5-10|<p>Your burnout level is high. This signals a need for active intervention โ€” seek support.</p>"

How it works:

  1. The engine evaluates the user's score against each --logic range
  2. First match wins (so order matters)
  3. The matched content replaces the widget's display
  4. If no logic matches, --value is the fallback

The High-Score-First Rule

Logic ranges must be added from highest to lowest:

# Correct order: high โ†’ mid โ†’ low
--logic "19-999|High interpretation" \
--logic "11-18|Mid interpretation" \
--logic "5-10|Low interpretation"

Why? Because the engine uses first-match-wins evaluation. If the low range is first and the user scores 20, they'd match the low range (5-10 doesn't match 20, but if ranges overlap, the first one wins). Ordering high-to-low ensures the most specific high range is checked first.

The Symptom Scale Trap

For negative-direction dimensions (symptom scales, high = bad), the logic interpretation direction must flip:

  • High score โ†’ empathy + improvement suggestions
  • Low score โ†’ affirmation + maintenance advice

For positive-direction dimensions (ability scales, high = good):

  • High score โ†’ affirmation + advanced challenges
  • Low score โ†’ support + foundational steps

The SKILL.md reads the direction field from scale data and adjusts interpretation direction accordingly. The AI doesn't need to figure this out โ€” the SKILL.md tells it: "if direction=negative for all dimensions, low total score = affirm good state, high total score = empathize + suggest improvement."

The PageTemplates Empty Report Problem

Sometimes the pageTemplates application fails silently โ€” the templates are declared but not applied. The report skill runs and finds an empty report (no pages exist).

The SKILL.md handles this with a two-mode strategy:

Normal mode (pages exist):

# Only update widgets โ€” pages are already created by templates
assess report widget update --page cover_1 --id title --value "..."

Empty report mode (no pages):

# Step 1: Create pages manually
assess report page update --page cover_1 --name "Cover" --backgroundColor "#1a1a2e"
# (response contains "โš ๏ธ Page was auto-created" โ€” confirms creation)

# Step 2: Add widgets (not update โ€” they don't exist yet)
assess report widget add --page cover_1 --type text --x 2 --y 3 --w 44 --h 6 ...

The detection is simple: if the report-pages reference shows no page rows, switch to empty report mode.

The Contrast Problem

Dark-themed reports (Noir, Mystic, Classic) need dark page backgrounds with light text. But the AI would frequently create dark-background pages with dark text โ€” invisible content.

The rule: fontColor and backgroundColor must be one dark, one light. Always.

Dark background (#000~#555 or rgba alpha < 0.3) โ†’ fontColor: #E2E8F0, #F1F5F9, #FFFFFF
Light background (#AAA~#FFF) โ†’ fontColor: #1A202C, #2D3748, #111827

The trap: rgba(255,255,255,0.06) โ€” a semi-transparent white that looks dark visually but has "255" in the RGB values. The AI would see "white" and assign dark text. The rule now explicitly covers this: "rgba with alpha < 0.3 is visually dark, use light fontColor."

The Resource Honesty Principle

The SKILL.md has a P0 rule about fabricated resources:

--value/--content HTML must NOT contain fabricated image URLs, external resources, or base64 data. Only plain text + HTML + inline CSS + CSS gradients are allowed.

The AI would sometimes generate <img src="https://example.com/chart.png"> โ€” a URL that doesn't exist. The PDF renderer would try to fetch it, fail, and produce a broken image icon in the report.

The only visual elements allowed are:

  • CSS gradients (background: linear-gradient(...))
  • Inline SVG (but see the SVG rules from the connect module post)
  • Text with styling

No external images. No base64 blobs. If the AI can't generate it with CSS, it doesn't go in the report.

Lessons Learned

  1. Templates are engine-computed, not AI-chosen. The AI declares intent (dimension count, theme); the engine picks templates. This eliminated an entire class of bugs.

  2. Always have a runtime fallback. If templates aren't applied, recover from in-memory scale data. Don't fail silently.

  3. Bare variables are the #1 report bug. Validate every --value and --logic for {{}} wrapping before accepting the command.

  4. Logic order matters. High-to-low, first-match-wins. Document this explicitly.

  5. Contrast validation must handle edge cases. Semi-transparent backgrounds, named colors, and hex shorthand all need explicit rules.

Next: [Expert Module โ€” Giving AI assistants personality and boundaries]

๐Ÿ“ฐ Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.