Skip to content

feat(storymap): PDF handout generator for the Story Map plugin#856

Merged
giswqs merged 9 commits into
mainfrom
feat/issue-830-storymap-pdf-handout
Jun 25, 2026
Merged

feat(storymap): PDF handout generator for the Story Map plugin#856
giswqs merged 9 commits into
mainfrom
feat/issue-830-storymap-pdf-handout

Conversation

@giswqs

@giswqs giswqs commented Jun 25, 2026

Copy link
Copy Markdown
Member

Summary

Adds a PDF Handout Generator to the Story Map plugin, as requested in #830. Presenters can now export the exact map views in a presentation as a clean, multi-page PDF handout instead of capturing screenshots by hand.

A new Handout (PDF) button in the Story Map panel opens a generator dialog with the three stages the request described:

  1. Screen selection - a checklist of every chapter view in the story, each with a checkbox, plus a Select all / Select none toggle. Export the whole story or any subset.
  2. Layout and page setup - paper size (A4, A3, Letter, Legal, Tabloid), portrait/landscape orientation, a custom document title, and custom footer text.
  3. PDF generation and output - flies the live map to each selected chapter in order, captures the rendered view, and assembles a multi-page PDF (one chapter per page) with the document title as a running header and the footer text plus page number at the bottom. The file is saved through the existing save bridge (native dialog on desktop, download/File System Access in the browser). The map returns to its original view when finished.

Implementation

  • apps/geolibre-desktop/src/lib/storymap-pdf.ts - pure, unit-tested PDF builder (jsPDF). One page per chapter; HTML in descriptions/footer is reduced to plain text; images are fit to the content box while preserving aspect ratio.
  • apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx - the generator dialog and the capture orchestration (jump to each chapter, wait for the map to settle, then capture). Map capture reuses the Print Layout captureMapImage helper.
  • StoryMapPanel.tsx - new Handout (PDF) button wired to the dialog.
  • i18n strings added to en.json.

Testing

  • tests/storymap-pdf.test.ts - unit tests for the PDF builder (valid PDF output, one page per chapter, no-chapter guard, title/footer-less render) and the HTML-to-text helper.
  • Full frontend suite green (1562 passing); production build and scoped pre-commit pass.
  • Verified end to end in the real web app with Playwright using the built-in sample story (five cities) in both light and dark themes: selected a subset of screens, generated the PDF, and confirmed the rendered pages show the document title header, chapter title, the actual captured map for each city, the description, and a clean (de-HTMLed) footer with page numbers.

Fixes #830

Summary by CodeRabbit

  • New Features
    • Added a PDF Handout Generator for Story Maps to select chapters, configure paper size/orientation, and set document title and footer.
    • Added a dedicated handout button that opens the generator and produces a multi-page PDF with chapter snapshots and optional chapter imagery.
    • Added UI localization for the new handout flow.
  • Bug Fixes
    • Improves generation reliability by restoring the original map view after export and surfacing capture/export errors.
    • Prevents closing the dialog while PDF generation is in progress.
  • Tests
    • Added unit tests covering handout text sanitization and PDF generation output.

Add a PDF Handout Generator to the Story Map panel (#830). A new
"Handout (PDF)" dialog lets the user select which chapter views to
include, choose the paper size and orientation, and set a document
title and footer. Generating flies the live map to each selected
chapter, captures the rendered view, and assembles a clean multi-page
PDF (one chapter per page) saved through the existing Tauri/browser
save bridge. The map returns to its original view when done.

The PDF builder (storymap-pdf.ts) is pure and unit tested; map capture
reuses the Print Layout captureMapImage helper.

Fixes #830
@netlify

netlify Bot commented Jun 25, 2026

Copy link
Copy Markdown

Deploy Preview for geolibre-app ready!

Name Link
🔨 Latest commit 3681fb6
🔍 Latest deploy log https://app.netlify.com/projects/geolibre-app/deploys/6a3c9869a3d7b900087c5ab4
😎 Deploy Preview https://deploy-preview-856--geolibre-app.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@giswqs, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 23 minutes and 59 seconds. Learn how PR review limits work.

To continue reviewing without waiting, enable usage-based billing in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: bb304020-0f15-439a-85bc-8b52758b41f4

📥 Commits

Reviewing files that changed from the base of the PR and between 04a1597 and 3681fb6.

📒 Files selected for processing (4)
  • apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx
  • apps/geolibre-desktop/src/i18n/locales/en.json
  • apps/geolibre-desktop/src/lib/storymap-pdf.ts
  • tests/storymap-pdf.test.ts
📝 Walkthrough

Walkthrough

This PR adds a Story Map handout export flow. It introduces a PDF builder, a dialog to choose chapters and page settings, panel wiring to open the dialog, localized strings, and tests for the PDF utilities.

Changes

Story Map handout export

Layer / File(s) Summary
PDF contracts and text helpers
apps/geolibre-desktop/src/lib/storymap-pdf.ts, tests/storymap-pdf.test.ts
Defines handout image/chapter/options types, normalizes HTML text, scales images, and verifies the text cleanup behavior.
Chapter rendering and PDF output
apps/geolibre-desktop/src/lib/storymap-pdf.ts, tests/storymap-pdf.test.ts
Builds per-chapter PDF pages, emits multi-page PDF bytes, and tests page counts, empty input handling, empty document options, and photo embedding.
Handout dialog export flow
apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx
Adds dialog state, map capture helpers, per-chapter generation progress, PDF saving, and the labeled field wrapper.
Panel wiring and localized copy
apps/geolibre-desktop/src/components/storymap/StoryMapPanel.tsx, apps/geolibre-desktop/src/i18n/locales/en.json
Adds the handout button, dialog mounting, supporting state, and localized labels and messages.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • opengeos/GeoLibre#299: Replaces the earlier Story Map HTML export path with the new handout export flow in the same panel area.

Poem

I hopped through chapters, nose to screen,
And bound them tight in PDF sheen.
One footer, one title, pages in a row,
A rabbit’s handout, neat to show. 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the new Story Map PDF handout generator.
Linked Issues check ✅ Passed The PR implements chapter selection, layout controls, ordered PDF generation, title/footer rendering, and local save flow as requested.
Out of Scope Changes check ✅ Passed The changes appear scoped to the Story Map PDF handout feature, including its dialog, PDF builder, locale strings, and tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-830-storymap-pdf-handout

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

⚡ Cloudflare Pages preview

Item Value
Preview https://599b7244.geolibre-preview.pages.dev
Demo app https://599b7244.geolibre-preview.pages.dev/demo/
Commit ca65560

Comment thread apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/lib/storymap-pdf.ts Outdated
Comment thread apps/geolibre-desktop/src/lib/storymap-pdf.ts
Comment thread apps/geolibre-desktop/src/lib/storymap-pdf.ts Outdated
Comment thread apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx Outdated
Chapters with an image now show that photo next to the captured map
view on their handout page (map left, photo right). The photo is
loaded into a canvas with CORS so it can be embedded; if it fails to
load or would taint the canvas the page falls back to the map alone.

Fixes #830

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx`:
- Around line 316-359: The StoryMap handout form fields in StoryMapHandoutDialog
are missing accessible label associations because Field only renders a visual
label. Update each Field/Select/Input pair to pass matching htmlFor and id
values so the controls get an accessible name, and apply the same fix to the
other affected fields later in the component. Use the existing
StoryMapHandoutDialog, Field, Select, and Input elements to locate the places
where the paper size, orientation, document title, and footer text controls are
rendered.
- Around line 76-103: `loadChapterPhoto` can hang forever on a pending remote
image request, blocking the handout export flow. Add a timeout inside
`loadChapterPhoto()` in `StoryMapHandoutDialog` so the Promise resolves to null
if `img.onload`/`img.onerror` never fires, and make sure the timeout is cleared
on success or failure; keep the existing fallback behavior by returning null
from the timed-out path.

In `@apps/geolibre-desktop/src/lib/storymap-pdf.ts`:
- Around line 117-120: The drawImageInBox helper is hard-coding the image format
passed to pdf.addImage, which breaks JPEG exports when HandoutImage.data
contains a JPEG data URL. Update drawImageInBox in storymap-pdf.ts to derive the
format from image.data (or otherwise pass the correct format dynamically)
instead of always using "PNG", so jsPDF uses the right parser for both PNG and
JPEG inputs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7b993b0f-9318-448b-aee2-e2e990c96e1f

📥 Commits

Reviewing files that changed from the base of the PR and between 261beac and 575b674.

📒 Files selected for processing (5)
  • apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx
  • apps/geolibre-desktop/src/components/storymap/StoryMapPanel.tsx
  • apps/geolibre-desktop/src/i18n/locales/en.json
  • apps/geolibre-desktop/src/lib/storymap-pdf.ts
  • tests/storymap-pdf.test.ts

Comment thread apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/lib/storymap-pdf.ts
Comment thread apps/geolibre-desktop/src/lib/storymap-pdf.ts Outdated
Comment thread apps/geolibre-desktop/src/lib/storymap-pdf.ts
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

# Finding Confidence
1 htmlToPlainText leaves <script>/<style> body text in output/<[^>]+>/g strips the tags but not their inner text (JS/CSS code), so an imported HTML chapter with a <style> block produces garbled text in the PDF. Fix: add a pre-pass `.replace(/<(script style)[^>]>[\s\S]?</\1>/gi, '')` before the generic tag strip. (Line 77 inline comment.)
2 Live-map race during capture — the map canvas remains interactive while the export loop runs. If the user pans between a jumpTo resolving and the idle event firing, finish() fires at the wrong position and captureMapImage captures the user's view instead of the chapter's. A pointer-events: none overlay on the map container during generation would prevent accidental interference. Low–medium

Performance

# Finding Confidence
3 All chapter canvases held in memory simultaneouslycaptures accumulates every HTMLCanvasElement before buildStoryMapHandoutPdf runs. For a 20-chapter story at 1920x1080 that is ~180 MB of pixel data at peak. Low priority for typical story sizes, but worth noting for large stories. Low

Quality

# Finding Confidence
4 No cancellation path — once generation starts the Cancel button is disabled and the ESC guard blocks dialog close, so a user who triggers a 15-chapter export is locked in for up to 75 s. A simple cancelledRef checked between iterations (with map-restore on cancel) would be a low-effort improvement. (Line 196 inline comment.) High
5 story.footer seeds the text input with raw HTML — the default story template footer is an anchor tag. The user sees markup in the input rather than readable text. Using htmlToPlainText(story.footer) at the seed site fixes the display without touching the PDF path. (Line 157 inline comment.) High
6 slugify silently drops non-ASCII characters — a purely non-Latin story title falls back to story-map-handout.pdf; accented Latin titles like "Sao Paulo" produce unexpected filenames. Unicode NFD normalisation before the ASCII replace preserves the Latin skeleton for accented characters. (Line 113 inline comment.) Medium

Security

Nothing found. htmlToPlainText output is drawn as text strings by jsPDF (never parsed as HTML), so injected markup has no effect. slugify output goes through the native file-picker filter, so no path injection is possible.

CLAUDE.md

All i18n strings are added to en.json (the source-of-truth locale). New user-facing strings use t(). No new tile/map hosts introduced. New files follow existing module conventions (lib/, components/storymap/). No issues.


Overall: The feature is well-structured — pure builder separated from orchestration, solid error recovery in the finally block, the jumpAndWaitIdle timeout prevents stalls on throttled tabs, and the test suite covers the key PDF invariants. The two high-confidence quality items (no cancel path, raw HTML in footer input) are straightforward fixes; the htmlToPlainText script-stripping issue is a one-liner.

giswqs added 2 commits June 24, 2026 22:31
- StoryMapHandoutDialog: seed the footer field with singleLine(story.footer)
  so the input shows readable text instead of raw HTML (Claude).
- StoryMapHandoutDialog: add a Stop button that aborts an in-progress
  export via an abort ref checked in the capture loop; the loop abandons
  the export when nothing was captured (Claude B1).
- StoryMapHandoutDialog: add a timeout to loadChapterPhoto so a hung
  remote image can no longer stall the whole export (CodeRabbit).
- StoryMapHandoutDialog: associate each field Label with its control via
  htmlFor/id for accessibility (CodeRabbit).
- storymap-pdf: decode extended named entities (mdash, ndash, hellip,
  smart quotes, etc.) and numeric (decimal/hex) entities (Claude).
- storymap-pdf: append an ellipsis when a description is truncated so the
  cut reads as intentional (Claude).
- storymap-pdf: derive the jsPDF image format from a data URL's MIME type
  so a JPEG is not handed to the PNG parser (CodeRabbit).
- storymap-pdf: correct the footer comment to describe first-line-only
  behaviour rather than width clipping (Claude nit).
- storymap-pdf: strip <script>/<style> blocks (with their text content)
  before the generic tag pass so imported HTML descriptions can't leak
  CSS/JS text into the handout.
- StoryMapHandoutDialog: NFD-normalize the slug so accented Latin titles
  (e.g. "São Paulo") keep their skeleton instead of dropping characters.
- StoryMapHandoutDialog: discard the export entirely when Stop is pressed
  rather than saving a partial PDF (the finally block still restores the
  map view).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx (1)

213-234: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Honor Stop before building or saving a partial PDF.

If the user clicks Stop after one chapter is captured, the next loop check breaks, but captures.length > 0 lets the code continue into PDF generation and the save prompt. Treat abortRef.current as a full cancellation before building/saving.

Proposed fix
       for (let i = 0; i < chosen.length; i++) {
         if (abortRef.current) break;
         const chapter = chosen[i];
         setProgress({ current: i + 1, total: chosen.length });
         await jumpAndWaitIdle(map, chapter.location);
+        if (abortRef.current) break;
         const shot = captureMapImage(map);
         // Load the chapter's own photo (if any) so it appears beside the map.
         const photo = chapter.image
           ? await loadChapterPhoto(chapter.image)
           : null;
+        if (abortRef.current) break;
         captures.push({
           title: chapter.title,
           description: chapter.description,
           map: { data: shot.image, width: shot.width, height: shot.height },
           ...(photo ? { photo } : {}),
         });
       }
-      // Stopped before capturing anything: abandon the export rather than
-      // writing an empty PDF.
-      if (captures.length === 0) {
+      // Stopped or captured nothing: abandon the export rather than writing a
+      // partial/empty PDF.
+      if (abortRef.current || captures.length === 0) {
         return;
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx`
around lines 213 - 234, The export flow in StoryMapHandoutDialog should treat
abortRef.current as a full cancellation, not just a loop break; after the
capture loop, check for cancellation before any PDF building or save prompt, so
a Stop click after one chapter prevents partial exports. Update the logic around
the captures array and the PDF generation/save path to exit early when
abortRef.current is set, using the existing jumpAndWaitIdle, captureMapImage,
and loadChapterPhoto flow as the reference point.
apps/geolibre-desktop/src/lib/storymap-pdf.ts (1)

248-268: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Let the image band collapse when the title already used the page.

If chapter.title wraps to many lines, bottomLimit - y - reservedForText can go negative here, but the Math.max(20, ...) floor still forces a 20 mm image block. That guarantees the image overprints the footer area on long-title/small-page exports.

Suggested fix
   const reservedForText = chapter.description ? 24 : 4;
-  const imageBandHeight = Math.max(20, bottomLimit - y - reservedForText);
+  const imageBandHeight = Math.max(0, bottomLimit - y - reservedForText);
   const gap = 5;
-  if (chapter.photo) {
+  if (imageBandHeight > 0 && chapter.photo) {
     // Map on the left, the chapter photo on the right, each fit into its own
     // half-width column and vertically centered within the band.
     const colWidth = (contentWidth - gap) / 2;
     drawImageInBox(pdf, chapter.map, MARGIN_MM, y, colWidth, imageBandHeight);
@@
-  } else {
+  } else if (imageBandHeight > 0) {
     // No photo: the map view spans the full content width.
     drawImageInBox(pdf, chapter.map, MARGIN_MM, y, contentWidth, imageBandHeight);
   }
-  y += imageBandHeight + 5;
+  if (imageBandHeight > 0) y += imageBandHeight + 5;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/geolibre-desktop/src/lib/storymap-pdf.ts` around lines 248 - 268, The
image band sizing in storymap-pdf should not force a minimum height when the
remaining page space is already exhausted by a wrapped title. Update the image
layout logic around the imageBandHeight calculation in storymap-pdf so it
collapses or skips drawing when bottomLimit - y - reservedForText is
non-positive, rather than always using Math.max(20, ...). Keep the behavior in
the chapter.photo branch and the no-photo drawImageInBox path consistent so
images never encroach on the footer area.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/geolibre-desktop/src/lib/storymap-pdf.ts`:
- Around line 91-97: The numeric entity handling in the text replacement
callback can still throw when a malformed code point like a very large reference
is parsed by the entity decoder in storymap-pdf’s replace logic. Update the
callback that processes matched entities so it validates the parsed numeric
value before calling String.fromCodePoint, and return the original match for
invalid or out-of-range values. Keep the fix localized to the entity-decoding
path in the text replacement function.

---

Outside diff comments:
In `@apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx`:
- Around line 213-234: The export flow in StoryMapHandoutDialog should treat
abortRef.current as a full cancellation, not just a loop break; after the
capture loop, check for cancellation before any PDF building or save prompt, so
a Stop click after one chapter prevents partial exports. Update the logic around
the captures array and the PDF generation/save path to exit early when
abortRef.current is set, using the existing jumpAndWaitIdle, captureMapImage,
and loadChapterPhoto flow as the reference point.

In `@apps/geolibre-desktop/src/lib/storymap-pdf.ts`:
- Around line 248-268: The image band sizing in storymap-pdf should not force a
minimum height when the remaining page space is already exhausted by a wrapped
title. Update the image layout logic around the imageBandHeight calculation in
storymap-pdf so it collapses or skips drawing when bottomLimit - y -
reservedForText is non-positive, rather than always using Math.max(20, ...).
Keep the behavior in the chapter.photo branch and the no-photo drawImageInBox
path consistent so images never encroach on the footer area.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5b49b393-c331-4cc1-97ec-22d8f50d8283

📥 Commits

Reviewing files that changed from the base of the PR and between 575b674 and 700312f.

📒 Files selected for processing (4)
  • apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx
  • apps/geolibre-desktop/src/i18n/locales/en.json
  • apps/geolibre-desktop/src/lib/storymap-pdf.ts
  • tests/storymap-pdf.test.ts

Comment thread apps/geolibre-desktop/src/lib/storymap-pdf.ts Outdated
- storymap-pdf: guard numeric HTML entity decoding against out-of-range
  code points so a malformed reference (e.g. &#99999999;) is left
  untouched instead of throwing and aborting the export.
Comment thread apps/geolibre-desktop/src/lib/storymap-pdf.ts
Comment thread apps/geolibre-desktop/src/lib/storymap-pdf.ts
Comment thread apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Reviewed StoryMapHandoutDialog.tsx, storymap-pdf.ts, StoryMapPanel.tsx (integration), en.json, and tests/storymap-pdf.test.ts. Also cross-checked print-layout-export.ts, print-layout.ts, tauri-io.ts, and the MapController.readView() / getMap() implementations for correctness of the surrounding context.


Bugs

# Finding Confidence
1 Stop after first chapter saves a silent partial PDF. The guard if (captures.length === 0) return only abandons the export when nothing was captured. If the user aborts after chapter 2 of 5, the remaining chapters are skipped but a 2-page PDF is still generated and saved with no indication it is truncated. See inline on lines 236–240. Medium-high
2 Stop button has up to 5 s latency. abortRef.current is only checked at the top of the for loop. While await jumpAndWaitIdle() is pending, setting the flag does nothing — the map keeps navigating and the dialog stays in "generating" state until the idle event fires or the 5 s timeout expires. See inline on lines 64–82. High
3 idle listener registered before jumpTo — premature resolution risk. If the map fires a residual idle event between map.on("idle", finish) and map.jumpTo(), the promise resolves before the map has moved and captureMapImage reads the previous frame. Reordering to register the listener after jumpTo eliminates this window. See inline on lines 73–80. Low-medium

Performance

# Finding Confidence
4 Footer text and page number can overlap on wide paper (e.g. Tabloid landscape). The footer is centered using contentWidth - 30 mm reserve, but the page number is right-aligned independently. For Tabloid landscape the two can be fewer than 3 mm apart and will visually collide when both are long. See inline on storymap-pdf.ts lines 239–246. Low-medium

Quality

# Finding Confidence
5 HTML-to-text strip regex misfires on > inside attribute values. /<[^>]+>/g stops at the first >, so <img alt="a > b"> leaves b"> as visible text in the PDF. Low impact (presentation only), but can garble chapter descriptions that contain comparison operators or arrows inside tag attributes. See inline on storymap-pdf.ts lines 120–125. Low
6 Dialog title field seeded with raw HTML story title. setTitle(story.title) copies HTML markup verbatim into the text input; singleLine is only applied later in drawChapterPage. setTitle(singleLine(story.title)) keeps the field readable from the start. See inline on lines 177–187. Low-medium

CLAUDE.md

No violations. i18n strings are added to en.json (the source-of-truth catalog) and all user-facing strings use t(). The new button respects the existing chapters.length === 0 disabled guard. The abortRef pattern avoids stale-closure issues. Test coverage covers the PDF builder and the HTML helper; the orchestration layer (dialog) is tested end-to-end per the PR description.


Overall: The feature is well-structured and the majority of the code is solid. The two behavioural issues worth fixing before merge are the silent partial PDF on Stop (#1) and the Stop-button latency (#2) — both are easy one-liners once the design intent (cancel-all vs. keep-partial) is decided.

Comment thread apps/geolibre-desktop/src/lib/storymap-pdf.ts
- StoryMapHandoutDialog: resolve jumpAndWaitIdle on the next frame when a
  chapter shares the previous chapter's exact location (a no-op jumpTo emits
  no idle event), instead of waiting the full timeout per such page.
- StoryMapHandoutDialog: skip crossOrigin for data: URI chapter photos so
  browsers that apply CORS to data images don't spuriously drop them.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx (1)

321-420: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Disable export settings while generation is running.

The chapter checkboxes and title/footer/layout controls remain editable during export, but handleGenerate() uses the values captured when Generate was clicked. Edits made mid-export look accepted in the dialog but won’t affect the saved PDF, which can produce surprising output. Disable these controls while generating is true.

Proposed fix
                 <Button
                   size="sm"
                   variant="ghost"
                   className="h-7 px-2 text-xs"
+                  disabled={generating}
                   onClick={toggleAll}
                 >
@@
                     <input
                       type="checkbox"
+                      disabled={generating}
                       checked={selected[chapter.id] ?? false}
@@
                 <Select
                   id="storymap-handout-paper-size"
+                  disabled={generating}
                   value={paperSize}
@@
                 <Select
                   id="storymap-handout-orientation"
+                  disabled={generating}
                   value={orientation}
@@
               <Input
                 id="storymap-handout-document-title"
+                disabled={generating}
                 value={title}
@@
               <Input
                 id="storymap-handout-footer"
+                disabled={generating}
                 value={footer}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx`
around lines 321 - 420, The export form in StoryMapHandoutDialog still allows
edits while handleGenerate() is running, which can mislead users because the PDF
uses the values captured at click time. In StoryMapHandoutDialog, disable the
chapter checkboxes, the toggleAll button, and the
paperSize/orientation/title/footer inputs whenever generating is true so the
current settings stay locked during export.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx`:
- Around line 321-420: The export form in StoryMapHandoutDialog still allows
edits while handleGenerate() is running, which can mislead users because the PDF
uses the values captured at click time. In StoryMapHandoutDialog, disable the
chapter checkboxes, the toggleAll button, and the
paperSize/orientation/title/footer inputs whenever generating is true so the
current settings stay locked during export.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6bf7a3b0-7336-4b5c-ba3e-146c6284623a

📥 Commits

Reviewing files that changed from the base of the PR and between 700312f and 04a1597.

📒 Files selected for processing (3)
  • apps/geolibre-desktop/src/components/storymap/StoryMapHandoutDialog.tsx
  • apps/geolibre-desktop/src/lib/storymap-pdf.ts
  • tests/storymap-pdf.test.ts

Comment thread apps/geolibre-desktop/src/lib/storymap-pdf.ts
Comment thread apps/geolibre-desktop/src/lib/storymap-pdf.ts
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Overall this is a well-structured addition: the PDF builder is cleanly separated from the dialog, the abort-and-restore pattern is correct, the HTML→plain-text conversion handles the common entity set, tests cover the main paths, and i18n strings are complete. The items below range from a real (if edge-case) layout bug to a missing import.


Bugs

# Finding Confidence
B1 Image band can overflow footer area for very long titles (storymap-pdf.ts:257-258). When the doc-title + chapter-title lines push y past bottomLimit - reservedForText, the Math.max(20, …) minimum clamp causes the image to be drawn over the footer band. The description loop already guards with if (y + lineHeightMm > bottomLimit) break; the image draw needs a symmetric guard. Medium
B2 &#0; inserts a null byte into PDF text (storymap-pdf.ts:99). The code >= 0 guard allows code point 0 → String.fromCodePoint(0)"\0". Null bytes in a PDF text stream can corrupt the document for some viewers/parsers. Fix: code >= 1. Medium

Performance

# Finding Confidence
P1 Photo load timeout reuses the map-idle constant (StoryMapHandoutDialog.tsx:106). Both map-settle (5 s) and chapter-photo load share IDLE_TIMEOUT_MS. For a 10-chapter story where every photo host times out, worst-case generation time is 10 × (5 s idle + 5 s photo) = 100 s. Photo loads degrade gracefully to map-only, so a shorter dedicated constant (e.g. 3 s) would improve responsiveness without affecting correctness. Medium

Quality

# Finding Confidence
Q1 Missing maplibregl import (StoryMapHandoutDialog.tsx:55). maplibregl.Map is used as a type without importing maplibre-gl. It resolves today via the library's UMD global type declaration, but every sibling component that uses maplibregl imports it explicitly (e.g. StoryMapPresenter.tsx). The reliance on the implicit global is fragile. Medium
Q2 Abort flag checked only at loop top (StoryMapHandoutDialog.tsx:224). After the user clicks Stop, the current jumpAndWaitIdle (up to 5 s) still completes, then captureMapImage and loadChapterPhoto (another up to 5 s) run for that chapter before the abort is detected on the next iteration. Adding if (abortRef.current) break immediately after await jumpAndWaitIdle eliminates the unnecessary work. High (low severity)
Q3 ?? footerText fallback is unreachable (storymap-pdf.ts:311). jsPDF.splitTextToSize always returns at least one element for non-empty input, and the enclosing if (footerText) block already excludes empty strings, so line is never undefined here. High
Q4 Checkboxes remain interactive during generation (StoryMapHandoutDialog.tsx:334). The chapter checkboxes are not disabled while the PDF is being built. The selection set is captured at the start of handleGenerate so correctness is unaffected, but a user toggling boxes mid-export might expect the change to apply. Adding disabled={generating} to the <input type="checkbox"> elements would prevent the confusion. Low

CLAUDE.md

No violations found. New i18n strings are present in en.json, the @geolibre/ui primitives are used for UI elements, and the PDF builder avoids direct MapLibre mutations.

Comment thread apps/geolibre-desktop/src/lib/storymap-pdf.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Reviewed StoryMapHandoutDialog.tsx, storymap-pdf.ts, StoryMapPanel.tsx, tests/storymap-pdf.test.ts, and en.json, plus the surrounding context files (print-layout-export.ts, print-layout.ts, tauri-io.ts, map-controller.ts).


Bugs

Image band overruns the footer for long chapter titlesmedium confidence, inline comment on storymap-pdf.ts L263–283

imageBandHeight is floored at 20 mm but the band's start position y is never clamped relative to bottomLimit. If a chapter title wraps many lines on small paper, y can already be past bottomLimit - reservedForText, so the 20-mm minimum pushes the images into or past the footer strip. The description-text loop guards correctly with a bottomLimit check, but the image-placement code does not. Suggestion: return early if available < 5 mm.


Quality

Silent abort — no feedback that export was cancelledhigh confidence, inline comment on StoryMapHandoutDialog.tsx L244–246

When the user clicks Stop and the early-return fires, finally clears the spinner but no message is set. The dialog returns to its idle state indistinguishably from a user who cancelled the save-file dialog (the dialog closes on a successful save). A one-line setError(t("storymap.handout.cancelled")) before the return is sufficient.

abortRef is not reset in the useEffect that runs on dialog openlow confidence (may be intentional)

abortRef.current is reset to false at the top of handleGenerate (before the loop), so the current code is safe. But if the dialog is reopened and the user inspects the button state before clicking Generate, it reflects stale ref state. Clearing it in the useEffect([open]) block alongside the other state resets would make the lifecycle more explicit.

IDLE_TIMEOUT_MS dual uselow confidence, inline comment on StoryMapHandoutDialog.tsx L109

The same 5-second constant covers both map tile-settle waits (where the full timeout is reasonable) and chapter photo loads (where a repeated 5-second DNS hang multiplied by every chapter is a noticeable stall). Separate constants would let each be tuned independently.


Performance

No significant issues beyond the photo-timeout concern above. The truncateWithEllipsis function is O(words²) per description line due to repeated getTextWidth calls, but descriptions are short in practice and jsPDF getTextWidth is a cheap ruler call, so this is not a real concern.


Security

No issues found. htmlToPlainText output is fed only to jsPDF text methods (never into the DOM), so there is no XSS surface. slugify strips everything outside [a-z0-9-] before passing the string to the native save-dialog default-name, which also does its own path handling. loadChapterPhoto correctly catches canvas-taint exceptions so a cross-origin image cannot abort the export.


CLAUDE.md

  • New user-facing strings are in en.json (storymap.handout.*) and use t() throughout — in line with the i18n convention. ✓
  • No direct MapLibre mutations from UI; store-driven flow is preserved. ✓
  • No changes to MapLibre control styling; dialog styles are self-contained. ✓
  • Branch and PR, not a direct push to main. ✓

giswqs added 2 commits June 24, 2026 22:50
- storymap-pdf: exclude code point 0 from numeric entity decoding so &#0;
  no longer inserts a null byte into the PDF text stream.
- storymap-pdf: skip the image band entirely when a long title has already
  consumed the page, instead of drawing a fixed 20mm band over the footer.
- storymap-pdf: drop the unreachable "?? footerText" fallback.
- StoryMapHandoutDialog: import maplibregl explicitly (matching sibling
  components) rather than relying on the ambient global.
- StoryMapHandoutDialog: give chapter-photo loading its own shorter timeout
  (3s) so a slow photo host doesn't double the per-chapter stall.
- StoryMapHandoutDialog: show an "Export cancelled" notice when the user
  stops an in-progress export, instead of silently returning to idle.
});
const saved = await saveBinaryFileWithFallback(bytes, {
defaultName: `${slugify(title || story.title)}-handout.pdf`,
filters: [{ name: t("storymap.handout.pdfFile"), extensions: ["pdf"] }],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (medium confidence): raw story.title HTML leaks into the filename when the document-title field is cleared.

story.title is a raw HTML string (story titles can contain markup like <em>Europe Tour</em>). When the user empties the "Document title" field and triggers the fallback, slugify converts the HTML delimiters to dashes — e.g. "<em>Paris</em>""em-paris-em-handout.pdf".

The title state is already stripped of HTML by singleLine() in the useEffect, so only the raw fallback is affected.

Suggested change
filters: [{ name: t("storymap.handout.pdfFile"), extensions: ["pdf"] }],
defaultName: `${slugify(title || singleLine(story.title))}-handout.pdf`,

// (the finally block still restores the map view).
if (abortRef.current || captures.length === 0) {
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX (low confidence / design question): no feedback to the user after a manual stop.

When the user clicks "Stop" the loop breaks here and the finally block clears both generating and progress, leaving the dialog in its pre-export idle state with no indication that the export was aborted. After watching "Capturing view 3 of 5…" for several seconds, a silent reset is a bit jarring.

If this is intentional (the user clearly chose to stop), it's fine as-is. If you want to surface confirmation, a brief setError(t("storymap.handout.stopped")) (with a muted colour rather than destructive red) here would be one option.

};
const timer = setTimeout(() => finish(null), PHOTO_TIMEOUT_MS);
// Only request CORS for real remote URLs. A data: URI has no origin, and
// some browsers fire `onerror` for `crossOrigin` data images, which would

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: IDLE_TIMEOUT_MS (named for the map-idle wait) is reused here for the photo-load timeout.

Both are 5 s, which is a reasonable value for each, but the constant name ("map idle timeout") is misleading in this context. A separate constant or an inline comment would clarify the intent:

Suggested change
// some browsers fire `onerror` for `crossOrigin` data images, which would
const timer = setTimeout(() => finish(null), 5000 /* photo load timeout */);

(Or introduce a PHOTO_LOAD_TIMEOUT_MS = 5000 constant near IDLE_TIMEOUT_MS.)

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Reviewed StoryMapHandoutDialog.tsx, storymap-pdf.ts, StoryMapPanel.tsx (wiring), en.json (i18n), and tests/storymap-pdf.test.ts. Also read surrounding context in print-layout-export.ts, print-layout.ts, sanitize-html.ts, and the MapController.readView implementation.


Bugs

Finding Confidence
slugify(title || story.title) uses raw HTML from story.title as the filename fallback. When the user clears the document-title field, HTML tags in the story title become dashes in the filename (e.g. "<em>Paris</em>"em-paris-em-handout.pdf). The title state is already sanitised by singleLine() in the effect; only the fallback is unprotected. Fix: slugify(title || singleLine(story.title)). (line 280) Medium

Security

Nothing flagged. htmlToPlainText output is used only for PDF text rendering (never injected into the DOM), so the regex-based tag stripper cannot create XSS. loadChapterPhoto correctly sets crossOrigin = "anonymous" only for non-data URIs and detects canvas taint via ctx.getImageData before using the image.


Performance

Nothing flagged. The sequential chapter-capture loop (one await jumpAndWaitIdle per chapter) is intentional for correct timing and not a performance concern.


Quality

Finding Confidence
After a user-triggered Stop the dialog silently resets to its pre-export idle state (progress cleared, no error). After watching "Capturing view 3 of 5…" for several seconds a silent reset is a bit jarring. Probably fine as-is, but a muted "Export stopped" note would be friendlier. (line 271) Low / design choice
IDLE_TIMEOUT_MS (named for the map-idle wait) is reused for the photo-load timeout in loadChapterPhoto. The value (5 s) is reasonable for both, but the name is misleading at the call-site. (line 131) Nit

CLAUDE.md

  • i18n strings added to en.json (source of truth) ✓
  • No new tile/map hosts introduced that would need a CSP update ✓
  • saveBinaryFileWithFallback used correctly (works in both web and desktop builds) ✓

What I verified was correct

  • Abort/stop mechanism: abortRef is polled every 150 ms inside jumpAndWaitIdle, the settled flag prevents double-resolution, and all timeouts/intervals/event listeners are cleaned up in finish(). No leaks.
  • No-op jump detection: the combination of center-equality check and map.areTilesLoaded() means the rAF fast path only fires when tiles are already in cache. A zoom/bearing change with uncached tiles still falls through to the idle wait. The ordering (MapLibre's render rAF fires before the rAF registered for finish, then captureMapImage calls map.redraw() for a synchronous re-render) is correct.
  • Map view restore: the finally block always restores the original view; the mapControllerRef.current?.readView() call returns a safe default if the map is uninitialised, but the earlier if (!map) return guard makes that branch unreachable in practice.
  • PDF builder math: bottomLimit, imageBandHeight, and truncation-with-ellipsis all guard degenerate inputs (Math.max bounds, the ellipsis loop exits cleanly on empty strings or a zero-word line).
  • Test reliability: the /Count N/ regex over the latin-1 representation of the PDF byte stream is a standard cross-reference entry and is a reliable page-count probe.

Comment on lines +88 to +110
});
// A no-op jump (an adjacent chapter sharing this exact location) changes
// nothing, so MapLibre fires no `idle` and the wait would hit the full
// timeout. The current frame is already rendered with tiles loaded, so
// resolve on the next frame instead.
const after = map.getCenter();
if (
before.lng === after.lng &&
before.lat === after.lat &&
map.areTilesLoaded()
) {
requestAnimationFrame(finish);
}
// Register after jumpTo so a pre-existing idle event isn't consumed before
// the new camera has started rendering.
map.on("idle", finish);
});
}

/**
* Load a chapter image URL (or data URI) into a canvas for embedding in the
* PDF. Resolves to null when the image fails to load or is cross-origin without
* CORS headers (so it would taint the canvas), letting the export proceed with

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (medium confidence) — no-op detection ignores zoom, pitch, and bearing

The comment says this path is for "a no-op jump (an adjacent chapter sharing this exact location)", but the condition only checks lng/lat, not zoom, pitch, or bearing. When two consecutive chapters share the same centre but differ in zoom (e.g. the classic "zoom in on a city across chapters" pattern), the condition fires requestAnimationFrame(finish) and the idle listener is removed before MapLibre has a chance to mark new-zoom tiles as pending. captureMapImage forces a redraw() but that renders with whatever tiles are in the buffer — tiles for the previous zoom — producing a blurry or tiled-pattern capture for that page.

The fix is to capture the full camera state before the jump and compare all four parameters:

Suggested change
});
// A no-op jump (an adjacent chapter sharing this exact location) changes
// nothing, so MapLibre fires no `idle` and the wait would hit the full
// timeout. The current frame is already rendered with tiles loaded, so
// resolve on the next frame instead.
const after = map.getCenter();
if (
before.lng === after.lng &&
before.lat === after.lat &&
map.areTilesLoaded()
) {
requestAnimationFrame(finish);
}
// Register after jumpTo so a pre-existing idle event isn't consumed before
// the new camera has started rendering.
map.on("idle", finish);
});
}
/**
* Load a chapter image URL (or data URI) into a canvas for embedding in the
* PDF. Resolves to null when the image fails to load or is cross-origin without
* CORS headers (so it would taint the canvas), letting the export proceed with
const before = map.getCenter();
const beforeZoom = map.getZoom();
const beforePitch = map.getPitch();
const beforeBearing = map.getBearing();
map.jumpTo({
center: location.center,
zoom: location.zoom,
pitch: location.pitch,
bearing: location.bearing,
});
// A no-op jump (an adjacent chapter sharing this exact location) changes
// nothing, so MapLibre fires no `idle` and the wait would hit the full
// timeout. The current frame is already rendered with tiles loaded, so
// resolve on the next frame instead.
const after = map.getCenter();
if (
before.lng === after.lng &&
before.lat === after.lat &&
beforeZoom === location.zoom &&
beforePitch === location.pitch &&
beforeBearing === location.bearing &&
map.areTilesLoaded()
) {
requestAnimationFrame(finish);
}
// Register after jumpTo so a pre-existing idle event isn't consumed before
// the new camera has started rendering.
map.on("idle", finish);

// wrongly drop an embedded photo.
if (!url.startsWith("data:")) img.crossOrigin = "anonymous";
img.onload = () => {
try {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit — in-flight image request is not cancelled on timeout

When PHOTO_TIMEOUT_MS fires, finish(null) correctly nulls out the handlers and resolves the promise, but the browser continues the network request in the background until it finishes or errors. For a story with many chapters all having slow remote photos, several concurrent fetches linger after each per-chapter timeout. Adding img.src = "" inside the timeout callback (before finish) aborts the request immediately:

Suggested change
try {
const timer = setTimeout(() => { img.src = ""; finish(null); }, PHOTO_TIMEOUT_MS);

Comment on lines +88 to +106
});
// A no-op jump (an adjacent chapter sharing this exact location) changes
// nothing, so MapLibre fires no `idle` and the wait would hit the full
// timeout. The current frame is already rendered with tiles loaded, so
// resolve on the next frame instead.
const after = map.getCenter();
if (
before.lng === after.lng &&
before.lat === after.lat &&
map.areTilesLoaded()
) {
requestAnimationFrame(finish);
}
// Register after jumpTo so a pre-existing idle event isn't consumed before
// the new camera has started rendering.
map.on("idle", finish);
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (medium-high confidence): No-op detection ignores zoom, bearing, and pitch

The fast-path only checks whether the map centre changed, so if two adjacent chapters share the same lat/lng but have different zoom levels (a common authoring pattern — e.g. zoom-in "chapter 1 overview → chapter 2 detail"), jumpTo triggers a tile load for the new zoom level, but the guard evaluates to true and resolves after a single animation frame instead of waiting for idle. The capture then fires before the new tile set is loaded, producing a blurry or partially-empty screenshot.

Suggested change
});
// A no-op jump (an adjacent chapter sharing this exact location) changes
// nothing, so MapLibre fires no `idle` and the wait would hit the full
// timeout. The current frame is already rendered with tiles loaded, so
// resolve on the next frame instead.
const after = map.getCenter();
if (
before.lng === after.lng &&
before.lat === after.lat &&
map.areTilesLoaded()
) {
requestAnimationFrame(finish);
}
// Register after jumpTo so a pre-existing idle event isn't consumed before
// the new camera has started rendering.
map.on("idle", finish);
});
}
const beforeZoom = map.getZoom();
const beforeBearing = map.getBearing();
const beforePitch = map.getPitch();
const before = map.getCenter();
map.jumpTo({
center: location.center,
zoom: location.zoom,
pitch: location.pitch,
bearing: location.bearing,
});
// A no-op jump (adjacent chapters sharing the exact same view) changes
// nothing, so MapLibre fires no `idle` and the wait would hit the full
// timeout. The current frame is already rendered with tiles loaded, so
// resolve on the next frame instead.
const after = map.getCenter();
if (
before.lng === after.lng &&
before.lat === after.lat &&
map.getZoom() === beforeZoom &&
map.getBearing() === beforeBearing &&
map.getPitch() === beforePitch &&
map.areTilesLoaded()
) {
requestAnimationFrame(finish);
}

Comment on lines +299 to +301
// Always return the map to where the user left it, even on failure.
if (original) {
map.jumpTo({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: dialog stays open on save cancellation

saveBinaryFileWithFallback returns null when the user dismisses the native save dialog without choosing a file (desktop) or cancels the File System Access picker (browser). In that case saved !== null is false, so the dialog stays open — which is intentional — but the user sees no feedback: the progress and error messages are both cleared by the finally block, and no notice is set on a cancelled save.

Consider setting a neutral notice (similar to the abort case) so the user knows the PDF was generated but not saved:

Suggested change
// Always return the map to where the user left it, even on failure.
if (original) {
map.jumpTo({
if (saved !== null) {
onOpenChange(false);
} else {
setNotice(t("storymap.handout.saveSkipped"));
}

(Requires a new i18n key, e.g. "saveSkipped": "PDF generated but not saved.")

boxW: number,
boxH: number,
): { width: number; height: number } {
if (w <= 0 || h <= 0) return { width: boxW, height: boxH };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: zero-dimension fallback in fitInto is misleading

When w ≤ 0 || h ≤ 0, the function returns { width: boxW, height: boxH }, which tells drawImageInBox to stretch the image to fill the box instead of skipping it. The path is unreachable in practice — loadChapterPhoto already returns null for zero-sized canvases and the map canvas always has positive dimensions — but as a standalone utility the contract is surprising.

A clearer fallback (no-op draw rather than a full-box stretch):

Suggested change
if (w <= 0 || h <= 0) return { width: boxW, height: boxH };
if (w <= 0 || h <= 0) return { width: 0, height: 0 };

@giswqs giswqs merged commit a32816b into main Jun 25, 2026
25 checks passed
@giswqs giswqs deleted the feat/issue-830-storymap-pdf-handout branch June 25, 2026 03:00
Comment on lines +106 to +111
assert.equal(count(one), 1);
assert.equal(count(three), 3);
});

it("throws when given no chapters", () => {
assert.throws(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: page-count heuristic matches the first /Count N in the byte stream, not necessarily the page tree

PDF objects frequently contain /Count in other contexts (e.g. font glyph count, form field count). A regex scan over the raw bytes finds the first match, which could be the wrong one if jsPDF ever reorders its output. The test passes today because jsPDF happens to emit the page tree /Count early, but it is brittle.

A slightly more robust approach is to count occurrences of /Page\n (the page type entry) or /Type /Page instead:

Suggested change
assert.equal(count(one), 1);
assert.equal(count(three), 3);
});
it("throws when given no chapters", () => {
assert.throws(
const count = (bytes: Uint8Array): number => {
const text = Buffer.from(bytes).toString("latin1");
// Count leaf page objects (/Type /Page) rather than scanning for /Count,
// which can appear in font or other dictionaries.
return (text.match(/\/Type\s*\/Page[^s]/g) ?? []).length;
};

Comment on lines +236 to +237
const docTitle = singleLine(options.title);
const footerText = singleLine(options.footer);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit — redundant singleLine calls

By the time buildStoryMapHandoutPdf is called, options.title and options.footer have already been processed through singleLine in the dialog's useEffect (lines 217–218 of StoryMapHandoutDialog.tsx) and then potentially edited as plain text by the user. Calling singleLine a second time here is idempotent so it never corrupts output, but it is silent overhead on every page. If the intent is to keep drawChapterPage independently safe for callers that supply raw HTML, a brief comment to that effect would clarify the design decision.

}

/** Reduce HTML/multi-line text to a single line of plain text for headers. */
export function singleLine(value: string): string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit — singleLine is exported but has no unit test

htmlToPlainText is well covered in tests/storymap-pdf.test.ts, but the additional step in singleLine — collapsing embedded newlines to spaces — is untested. Since the function is part of the public API (imported by the dialog to pre-populate the title/footer fields), adding one or two cases to the test suite (e.g. multi-line HTML titles) would complete the coverage.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • Incomplete no-op detection in jumpAndWaitIdle (StoryMapHandoutDialog.tsx lines 100–106) — high confidence. The fast-path that skips the idle wait only compares centre (lat/lng), so adjacent chapters that share the same geographic centre but differ in zoom, bearing, or pitch will trigger a tile load yet immediately resolve via requestAnimationFrame. The map capture then fires before new tiles are loaded, producing a blurry or partially-rendered screenshot. Fix: capture and compare zoom, bearing, and pitch before/after jumpTo, the same way center is already checked. (See inline comment.)

Quality

  • No feedback when the save dialog is dismissed without saving (StoryMapHandoutDialog.tsx line 301) — medium confidence. When saveBinaryFileWithFallback returns null (user cancelled the native dialog), the dialog silently idles: the progress and error messages are both cleared by the finally block and no notice is set. The user has no indication that the PDF was generated but just not written. Setting a neutral notice (analogous to the abort case) would clarify the state. (See inline comment.)

  • fitInto zero-dimension fallback stretches instead of no-ops (storymap-pdf.ts line 173) — low confidence / nit. return { width: boxW, height: boxH } for a zero-sized image would tell jsPDF to draw it at full box size rather than skipping it. The path is unreachable in practice (both call sites guard against zero dimensions upstream), but the intent would be clearer as { width: 0, height: 0 }. (See inline comment.)

Tests

  • Fragile page-count regex (tests/storymap-pdf.test.ts lines 106–111) — low confidence / nit. The /\/Count (\d+)/ scan finds the first match in the raw PDF byte stream, which works today because jsPDF places the page-tree /Count early, but /Count also appears in font and other dictionaries. Matching /Type\s*\/Page[^s]/ (leaf page objects) would be more robust. (See inline comment.)

What was checked but found clean

  • Security: htmlToPlainText correctly strips <script>/<style> blocks before the generic tag strip; entity decoding guards against null bytes and out-of-range code points; cross-origin canvas tainting is caught and treated as "no photo" rather than aborting the export.
  • Abort path: React 18 batches the setNotice (try block) and setProgress(null) (finally block) into a single render, so the "cancelled" notice is never hidden behind a stale progress indicator.
  • Map view restore: The original view is captured before the loop and always restored in finally, even on error or abort.
  • i18n: All new UI strings are present in en.json; pluralization keys (count/total) match the interpolation syntax.
  • Dialog close guard: onOpenChange is blocked during generation (!generating && onOpenChange(next)), preventing the user from closing mid-export.
  • CLAUDE.md: UI strings use t(); the new component is in the correct directory; no direct MapLibre mutations from UI code.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

# Finding Confidence
B1 No-op jump detection in jumpAndWaitIdle ignores zoom, pitch, and bearingStoryMapHandoutDialog.tsx lines 99–106. The condition checks only lng/lat, not zoom/pitch/bearing. When two consecutive chapters share the same centre but differ in zoom, areTilesLoaded() can return true immediately after jumpTo (before MapLibre has issued tile requests for the new zoom level). The rAF fires, the idle listener is removed, and captureMapImage runs without waiting for those tiles — producing a PDF page that shows the previous zoom level's tiles scaled to the new camera. See inline comment for the fix. Medium

Performance

# Finding Confidence
P1 In-flight image download not cancelled on photo timeoutStoryMapHandoutDialog.tsx line 135. When PHOTO_TIMEOUT_MS fires, handlers are cleared and the promise resolves to null, but the browser continues the HTTP fetch in the background. For a story with many chapters and slow remote photos, several concurrent downloads linger after each per-chapter timeout. Adding img.src = "" in the timeout callback aborts the request immediately (see inline comment). Low

Quality

# Finding Confidence
Q1 Redundant singleLine() calls in drawChapterPagestorymap-pdf.ts lines 236–237. options.title and options.footer are already sanitised before reaching buildStoryMapHandoutPdf (the dialog seeds them through singleLine() on open, and the user then edits plain text). The second pass is idempotent but redundant. If the intent is to keep drawChapterPage safe for callers that pass raw HTML, a short comment would clarify; otherwise the double-call can be dropped. Low
Q2 singleLine exported but not unit testedstorymap-pdf.ts line 144. htmlToPlainText is well covered, but the extra newline-collapse step unique to singleLine has no test. It is part of the public API (imported by the dialog to pre-populate the title/footer fields). A couple of extra cases in storymap-pdf.test.ts would close the gap. Low

Security / CLAUDE.md

No issues found. HTML-to-text stripping is for PDF text output only (never browser rendering), so incomplete entity handling carries no XSS risk. loadChapterPhoto CORS/taint handling is correct. Only paper-group sizes are offered in the dropdown, ruling out pixel-based sizes leaking into the jsPDF mm layout. New i18n keys are complete and consistent with the existing en.json structure.

What was checked

  • jumpAndWaitIdle idle-event registration order, abort-poll timing, and no-op detection logic
  • Early-resolve interaction with captureMapImage redraw() call
  • loadChapterPhoto timeout, CORS flag selection, and canvas-taint paths
  • htmlToPlainText / decodeEntities regex edge cases (script/style blocks, quoted > in attributes, numeric entities, null bytes)
  • drawChapterPage layout arithmetic (footerY / bottomLimit / imageBandHeight)
  • handleGenerate state lifecycle: abort ref, progress state, finally-block view restoration, dialog-close guard
  • useEffect([open]) intentional dependency suppression
  • Handout button disabled-when-empty wiring in StoryMapPanel
  • i18n key completeness in en.json
  • Test suite coverage

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: PDF Handout Generator for Story Map Plugin

1 participant