diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml
index dd993135..d37447a2 100644
--- a/.github/workflows/flutter-linux.yml
+++ b/.github/workflows/flutter-linux.yml
@@ -33,6 +33,7 @@ jobs:
clang \
cmake \
fonts-noto-core \
+ fonts-noto-cjk \
fonts-noto-mono \
ninja-build \
pkg-config \
diff --git a/assets/export/markdown.typ b/assets/export/markdown.typ
index 8921201d..7624db60 100644
--- a/assets/export/markdown.typ
+++ b/assets/export/markdown.typ
@@ -15,8 +15,20 @@
#let geometry = options.page
#let margins = geometry.marginsPt
#let content-height = (geometry.heightPt - margins.top - margins.bottom) * 1pt
-#let running-visible() = options.showHeaderFooterOnFirstPage or counter(page).get().first() > 1
+#let has-title-page = options.at("includeTitlePage", default: false)
+#let running-visible() = {
+ let first = counter(page).get().first() == 1
+ (not first or not has-title-page) and (not first or options.showHeaderFooterOnFirstPage)
+}
#let number-position = if options.pageNumbers == "bottomLeft" { left } else if options.pageNumbers == "bottomRight" { right } else { center }
+#let footer-content(font-size) = {
+ set text(size: font-size, top-edge: "ascender", bottom-edge: "descender")
+ set par(leading: 0.2em, spacing: 0pt)
+ let rows = ()
+ if options.footer == "documentTitle" { rows.push(align(center, text(document-metadata.title))) }
+ if options.pageNumbers != "off" { rows.push(align(number-position, counter(page).display("1"))) }
+ stack(spacing: 0.2em, ..rows)
+}
#set page(
width: geometry.widthPt * 1pt,
height: geometry.heightPt * 1pt,
@@ -29,9 +41,15 @@
header: if options.header == "none" { none } else { context { if running-visible() { text(document-metadata.title) } } },
footer: if options.footer == "none" and options.pageNumbers == "off" { none } else { context {
if running-visible() {
- // Native page footer content combines title and counter without overlap.
- if options.footer == "documentTitle" { align(center, text(document-metadata.title)) }
- if options.pageNumbers != "off" { align(number-position, counter(page).display("1")) }
+ // The footer starts 30% into the bottom margin. Fit the complete stack
+ // into the remaining space, including font ascenders and descenders.
+ layout(size => {
+ let font-size = typography.bodySizePt * 1pt
+ let available-height = calc.max(1pt, margins.bottom * 0.7pt - 2pt)
+ let natural = measure(footer-content(font-size), width: size.width)
+ let factor = calc.min(1, available-height / natural.height)
+ footer-content(font-size * factor)
+ })
}
} },
)
@@ -100,17 +118,37 @@
}
}
-#let render-inlines(items) = {
+// Definitions remain in the semantic tree so asset/math preparation also
+// visits their content. Only the first reference emits their page footnote.
+#let collect-footnotes(blocks) = {
+ let result = (:)
+ for item in blocks {
+ if "footnoteId" in item { result.insert(item.footnoteId, item) }
+ result += collect-footnotes(value-or(item, "children", ()))
+ }
+ result
+}
+#let footnotes = collect-footnotes(data.blocks)
+
+#let render-inlines(items, render) = {
for item in items {
let kind = item.kind
let children = value-or(item, "children", ())
let body = if children.len() > 0 {
- render-inlines(children)
+ render-inlines(children, render)
} else {
text(value-or(item, "text", ""))
}
- if kind == "text" {
+ if "footnoteId" in item {
+ let id = item.footnoteId
+ let target = label("pdf-note-" + id)
+ if item.footnoteFirst == "true" {
+ [#footnote(for child in value-or(footnotes.at(id), "children", ()) { render(child) })#target]
+ } else {
+ footnote(target)
+ }
+ } else if kind == "text" {
body
} else if kind == "strong" {
strong(body)
@@ -184,7 +222,7 @@
let is-header = value-or(row, "header", false)
let row-cells = ()
for cell in value-or(row, "children", ()) {
- let cell-body = [#render-inlines(value-or(cell, "inlines", ()))#for child in value-or(cell, "children", ()) { render(child) }]
+ let cell-body = [#render-inlines(value-or(cell, "inlines", ()), render)#for child in value-or(cell, "children", ()) { render(child) }]
let alignment = value-or(cell, "align", "left")
let cell-align = if alignment == "center" {
center
@@ -223,11 +261,13 @@
#let render-list(block-data, render) = {
let list-type = value-or(block-data, "listType", "bullet")
let items = value-or(block-data, "children", ()).map(item => {
+ let anchor = value-or(item, "anchor", "")
+ if anchor != "" { [#metadata(anchor) #label(anchor)] }
let task = value-or(item, "task", none)
if task != none {
box(width: 1.35em, text(if task { "☑" } else { "☐" }))
}
- render-inlines(value-or(item, "inlines", ()))
+ render-inlines(value-or(item, "inlines", ()), render)
let nested = value-or(item, "children", ())
if nested.len() > 0 {
for nested-block in nested { render(nested-block) }
@@ -271,6 +311,7 @@
}
#let render-block(block-data) = {
+ if "footnoteId" in block-data { return [] }
let kind = block-data.kind
let inlines = value-or(block-data, "inlines", ())
let children = value-or(block-data, "children", ())
@@ -280,12 +321,12 @@
let element = heading(
level: value-or(block-data, "level", 1),
outlined: value-or(block-data, "outlined", true),
- render-inlines(inlines),
+ render-inlines(inlines, render-block),
)
let anchor = value-or(block-data, "id", "")
if anchor == "" { element } else { [#element #label(anchor)] }
} else if kind == "paragraph" {
- par(render-inlines(inlines))
+ par(render-inlines(inlines, render-block))
} else if kind == "code" {
let language = value-or(block-data, "language", "")
block(
@@ -322,7 +363,7 @@
let body = if children.len() > 0 {
for child in children { render-block(child) }
} else {
- render-inlines(inlines)
+ render-inlines(inlines, render-block)
}
let default-title = if style == "warning" {
"Warning"
@@ -341,7 +382,7 @@
let body = if children.len() > 0 {
for child in children { render-block(child) }
} else {
- render-inlines(inlines)
+ render-inlines(inlines, render-block)
}
render-callout(
body,
@@ -356,7 +397,7 @@
if item.kind == "image" {
render-display-image(item)
} else {
- render-inlines((item,))
+ render-inlines((item,), render-block)
}
}
}
@@ -424,15 +465,95 @@
raw(value-or(block-data, "text", ""), block: true),
)
} else if kind == "group" {
+ if inlines.len() > 0 { par(render-inlines(inlines, render-block)) }
for child in children { render-block(child) }
} else {
- render-inlines(inlines)
+ render-inlines(inlines, render-block)
+ }
+}
+
+#let render-title-page() = context {
+ let cover = data.titlePage
+ let body-size = typography.bodySizePt * 1pt
+ let width = (geometry.widthPt - margins.left - margins.right) * 1pt
+ let fit-error = "The PDF title page does not fit the selected page size and margins. Shorten the title-page text or choose a larger content area."
+ let cover-text(value) = context {
+ if measure(text(value), width: auto).width > width {
+ assert(measure(text(value), width: width).height <= content-height,
+ message: fit-error)
+ // At zero width, Typst puts each Unicode-unbreakable span on its own
+ // line. Probe two adjacent visible graphemes with 1pt top edges and give
+ // all other text zero-height metrics: a height of 2pt means a legal break
+ // separates them. Keep the complete string in every probe so punctuation,
+ // combining marks, non-breaking spaces, and word joiners retain context.
+ // Only the probe's height identifies breaks; it never proves width fits.
+ let start = 0
+ let offset = 0
+ let previous = none
+ for cluster in value.clusters() {
+ let next = offset + cluster.len()
+ if not cluster.contains(regex("^[\\s\\p{Default_Ignorable_Code_Point}]+$")) {
+ if previous != none {
+ let probe = {
+ set text(top-edge: 0pt, bottom-edge: 0pt, hyphenate: false)
+ set par(leading: 0pt, spacing: 0pt, linebreaks: "simple")
+ text(value.slice(0, previous))
+ text(top-edge: 1pt, value.slice(previous, next))
+ text(value.slice(next))
+ }
+ if measure(probe, width: 0pt).height > 1.5pt {
+ // Native soft breaks trim trailing whitespace. Check the span
+ // unconstrained, using the actual cover font, weight, and size.
+ let span = value.slice(start, offset).trim(at: end)
+ assert(measure(text(span), width: auto).width <= width,
+ message: fit-error)
+ start = offset
+ }
+ }
+ previous = offset
+ }
+ offset = next
+ }
+ assert(measure(text(value.slice(start)), width: auto).width <= width,
+ message: fit-error)
+ }
+ // Keep the original text intact for native shaping and Unicode wrapping.
+ text(value)
}
+ let cover-content = {
+ set text(font: typography.bodyFont, size: body-size,
+ top-edge: "ascender", bottom-edge: "descender", overhang: false)
+ set par(leading: 0.4em, spacing: 0pt)
+ let rows = (text(size: 2.4 * body-size, weight: "bold",
+ fill: rgb(options.accentColor), cover-text(cover.title)),)
+ let subtitle = cover.at("subtitle", default: "")
+ if subtitle != "" { rows.push(text(size: 1.3 * body-size, cover-text(subtitle))) }
+ let details = ()
+ for key in ("author", "organization", "version", "date") {
+ let value = cover.at(key, default: "")
+ if value != "" { details.push(cover-text(value)) }
+ }
+ if details.len() > 0 {
+ rows.push(stack(spacing: 0.5em, ..details))
+ }
+ align(center, stack(spacing: 1.5em, ..rows))
+ }
+ let natural = measure(cover-content, width: width)
+ assert(natural.height <= content-height, message: fit-error)
+ block(width: width, height: content-height, breakable: false, above: 0pt, below: 0pt,
+ align(center + horizon, cover-content))
+}
+
+// Breaks stay in the top-level flow. A native outline may span many pages;
+// neither the cover nor the outline replaces or changes source headings.
+#if has-title-page {
+ render-title-page()
+ pagebreak(weak: true)
}
#if options.content.includeToc {
outline(depth: options.content.tocDepth)
- v(1em)
+ pagebreak(weak: true)
}
#for block-data in data.blocks { render-block(block-data) }
diff --git a/docs/development/export-verification.md b/docs/development/export-verification.md
index ffca9676..57bac76e 100644
--- a/docs/development/export-verification.md
+++ b/docs/development/export-verification.md
@@ -11,6 +11,10 @@ export paths. User-facing settings and limitations are documented in
shared by the export dialogs.
- `lib/src/export/export_options_editor.dart` provides the Markdown and
Writerside settings editors.
+- `lib/src/export/pdf_title_page.dart` carries immutable, export-owned plain
+ text. Only `PdfExportOptions.includeTitlePage` is persisted. Markdown defaults
+ and body content share an editor snapshot; Writerside defaults follow the
+ selected instance. HTML and shared content options have no cover setting.
- `lib/src/export/typst_payload_builder.dart`,
`lib/src/export/markdown_export_mapper.dart`, and `assets/export/markdown.typ`
implement the native PDF model and layout.
@@ -37,6 +41,65 @@ metadata, TOC hierarchy and numbering, CSS validation, embedded and external
assets, math and diagram rendering, Writerside resolution, cancellation, and
preservation of existing output after failure.
+## PDF front matter and actual rendering
+
+Set the bundled compiler path explicitly; skipped rendering tests do **not**
+verify pagination. Poppler’s `pdftotext`, `pdftohtml`, `pdfinfo`, and `pdffonts`
+provide text, link destinations, geometry, and font inspection:
+
+```bash
+export BUSYMARK_TYPST_PATH="$PWD/build/linux/x64/release/bundle/libexec/busymark/typst"
+export BUSYMARK_D2_PATH="$PWD/build/linux/x64/release/bundle/libexec/busymark/d2"
+flutter test --no-pub test/src/pdf_title_page_test.dart \
+ test/src/pdf_title_page_ui_test.dart test/src/pdf_front_matter_render_test.dart \
+ test/src/pdf_export_options_render_test.dart
+flutter test --no-pub
+```
+
+`pdf_title_page_test.dart` covers default-disabled serialization, copying,
+metadata precedence, plain-text payloads, clearing fields, validation, and
+separation from global settings. `pdf_title_page_ui_test.dart` exercises reset,
+cancel, format switching, instance changes, metadata editing, and both direct
+and unified export routes. Markdown defaults must reflect unsaved editor text,
+and later edits must not change an already captured export request.
+
+`pdf_front_matter_render_test.dart` compiles real Markdown and Writerside PDFs.
+For a one-page body and one-page TOC, title-page/TOC combinations off/off,
+on/off, off/on, and on/on must yield 1, 2, 2, and 3 pages. Body paragraph
+sentinels, not duplicated heading text, locate actual body pages. A 115-heading
+fixture requires a multipage native outline and checks every heading link and
+displayed page reference against its body paragraph’s physical page. Additional
+checks cover nested/depth-limited and numbered headings, excluded Writerside
+headings, bookmarks without a printed TOC, both body fonts, portrait/landscape,
+custom margins, Unicode, multiline metadata, and unchanged source files.
+Unbroken titles and every metadata field are exercised on A4 and narrow custom
+pages through the real export service. Success must retain all cover text and
+keep text bounds inside the content area; a clear fit failure must preserve the
+previous PDF byte for byte. Title and subtitle cases also check their larger
+rendered sizes. A constrained Typst paragraph frame width is not evidence that
+its glyphs fit.
+The CJK regressions additionally require `fonts-noto-cjk` (installed in CI).
+They add that font to an isolated test compiler bundle, require a Chinese title
+without spaces to succeed on exactly two lines with complete text and valid
+bounds, and require overwide sequences joined by non-breaking spaces or word
+joiners to fail without replacing the destination. These are layout tests;
+they do not extend the product bundle's font coverage.
+
+The title page counts as physical page 1 but never emits running text or its
+visible number. Subsequent pages obey configured header/footer placement and
+the physical-first-page preference. Logical numbering stays enabled so the TOC
+retains destination numbers even with visible numbers off. Oversized cover
+content must fail clearly; failure and cancellation must preserve an existing
+destination. Keep the existing anchor, list, module-resolution, diagram, and
+HTML regression suites enabled.
+
+For visual review, optionally set `BUSYMARK_PDF_ARTIFACTS` to a fresh temporary
+directory while running the front-matter tests, then render representative
+cover, TOC, and body pages with `pdftoppm -png`. Inspect wrapping, margins,
+clipping, overlap, empty optional fields, blank pages, and typography isolation.
+Record actual pass/failure/skip counts separately, including the compiler used;
+a passing model or template-string test is not a substitute for rendered output.
+
## Release product-path smoke
The release-only smoke entry point is gated by `BUSYMARK_RELEASE_SMOKE=1`. Write
diff --git a/docs/html-export.md b/docs/html-export.md
index 70c848f7..8a314323 100644
--- a/docs/html-export.md
+++ b/docs/html-export.md
@@ -1,8 +1,9 @@
# HTML export
-For Markdown, choose **Outline → Actions → Export** or use the command palette.
-For Writerside, choose **Topics → Actions → Export**. HTML has no default shortcut;
-**Ctrl+Shift+E** continues to export PDF.
+Choose **Main menu → Export** or use the command palette. The contextual action
+also remains under **Outline → Actions** for Markdown and **Topics → Actions**
+for Writerside. HTML has no default shortcut; **Ctrl+Shift+E** continues to
+export PDF.
Markdown exports the active document’s current editor text, including unsaved
and untitled content. Writerside first offers Save, Discard, or Cancel for
diff --git a/docs/pdf-export.md b/docs/pdf-export.md
index 32350230..ed6bb69f 100644
--- a/docs/pdf-export.md
+++ b/docs/pdf-export.md
@@ -1,10 +1,11 @@
# PDF export
-For Markdown, choose **Outline → Actions → Export**, use the command palette,
-or press **Ctrl+Shift+E**. Markdown exports the active editor text, including
-unsaved and untitled documents. For Writerside, choose **Topics → Actions → Export**;
-BusyMark first offers Save, Discard, or Cancel for project changes, then lets
-you select a non-library instance. The shortcut and command palette route use
+Choose **Main menu → Export**, use the command palette, or press
+**Ctrl+Shift+E**. The contextual action also remains under
+**Outline → Actions** for Markdown and **Topics → Actions** for Writerside.
+Markdown exports the active editor text, including unsaved and untitled
+documents. For Writerside, BusyMark first offers Save, Discard, or Cancel for
+project changes, then lets you select a non-library instance. Every route uses
the same document-versus-instance scope.
The export settings are independent of the editor and preview appearance.
@@ -12,7 +13,16 @@ Markdown and Writerside offer the same PDF controls:
- **Content:** include a table of contents, choose its heading depth from 1–6,
and number headings. Numbering does not modify your source. The table of
- contents uses the document’s headings and page numbers.
+ contents uses the document’s headings, links, and destination page numbers.
+ It starts on a separate page, can span multiple pages, and is always followed
+ by a page break before the document body.
+- **Title page:** optionally include a generated title page. Enabling it shows
+ editable Title, Subtitle, Author, Organization, Version, and Date fields.
+ Title is required; empty optional fields are omitted. This page never shows
+ a running header, footer, or visible page number. Cover text uses Unicode-aware
+ wrapping, including Chinese and Japanese text without spaces. If an unbreakable
+ sequence is wider than the content area or the cover is too tall, export
+ reports a fit error and preserves any existing destination PDF.
- **Page:** A4, US Letter, US Legal, or custom dimensions in millimetres;
portrait or landscape; narrow, normal, wide, or four independent custom
margins. Custom dimensions are 50–1200 mm. Margins must leave at least 20 mm
@@ -27,20 +37,57 @@ Markdown and Writerside offer the same PDF controls:
- **Appearance:** an accent/link color, chosen from the palette or entered as
`#RRGGBB`.
-After you confirm the settings, BusyMark remembers them globally for the next
-PDF export, including exports from other documents and Writerside modules.
-**Reset to defaults** restores the default values in the dialog. Cancel leaves
-your remembered settings unchanged. PDF and HTML remember separate settings.
+After you confirm the settings, BusyMark remembers reusable PDF preferences
+globally, including the title-page toggle. **Title-page text belongs only to
+the current export** and is never saved as a default for other documents.
+**Reset to defaults** disables the title page and restores its text fields to
+the current document’s metadata defaults. Toggling the title page off and back
+on retains your edits within that dialog. Cancel leaves remembered settings
+unchanged and discards the dialog’s text edits. PDF and HTML remember separate
+settings; HTML has no title-page option.
Defaults are A4 portrait, normal margins, 10.5 pt serif body text, 8.4 pt code,
-blue links, and centered page numbers on all pages. Generated TOC and heading
-numbering are off. Custom page dimensions describe the portrait geometry;
+blue links, and centered page numbers on all pages. Generated title page, TOC,
+and heading numbering are off. Custom page dimensions describe the portrait geometry;
landscape swaps the width and height.
Choose the destination after confirming settings. BusyMark asks before
replacing an existing file. Export can be cancelled, and a failed export keeps
the previous PDF usable.
+## Title-page defaults and pagination
+
+Markdown uses the same captured editor text for metadata and document content,
+including unsaved changes. Title defaults follow the existing precedence:
+explicit title override, front-matter title, document title, filename, then
+“Untitled”. Author accepts `author` or `authors` metadata. Optional `subtitle`,
+`organization`, `version`, and `date` come from source metadata when present.
+For a Writerside instance, the default title is the selected instance’s display
+name, not the open topic’s title. An existing instance or inherited project
+version supplies Version; unavailable metadata stays empty. Selecting another
+instance refreshes untouched fields without overwriting edited text.
+
+You can override any field for this export. Explicitly clearing an optional
+field keeps it omitted. Date and Version are plain display text: BusyMark does
+not infer them from your account, Git, application version, or file timestamps.
+All fields remain plain text, never executable Typst or Markdown. The cover
+uses your selected body font, accent color, page geometry, and margins. Text
+wraps; if it cannot fit one page, export fails with a message asking you to
+shorten it or enlarge the content area. It is not clipped or spilled into the TOC.
+
+Sections appear in this order: optional title page, optional TOC, then the
+unchanged document body. Each enabled front-matter section starts on a new
+page. A long TOC continues naturally, and the body never shares its last page.
+No odd-page alignment or empty separator pages are added. The generated title
+and TOC heading do not become numbered body sections or TOC entries. The
+original first source heading, body anchors, and PDF bookmarks are retained.
+
+Page numbers remain continuous Arabic numbers, including the hidden title-page
+number. With one title page and one TOC page, the body begins on physical page
+3. Turning visible page numbers off does not remove the TOC’s destination page
+references. **Show on first page** still means physical page 1, not the first
+body page; a title page always suppresses running text regardless of that setting.
+
## Intentional blank lines
Use **Insert blank line** in Editor view when a specific one-line gap is part of
diff --git a/docs/writerside-pdf-export.md b/docs/writerside-pdf-export.md
index 1a28d03b..ea631e9f 100644
--- a/docs/writerside-pdf-export.md
+++ b/docs/writerside-pdf-export.md
@@ -6,9 +6,10 @@ are not required.
## Export
-Open a Writerside module and select **Topics → Actions → Export**, use the command
-palette, or press Ctrl+Shift+E. BusyMark first
-asks you to save or discard unsaved project changes, then lets you select:
+Open a Writerside module and select **Main menu → Export** or
+**Topics → Actions → Export**, use the command palette, or press
+Ctrl+Shift+E. BusyMark first asks you to save or
+discard unsaved project changes, then lets you select:
- one non-library Writerside instance;
- a generated table of contents with heading depth 1–6 and optional heading numbering;
diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb
index 2c6ff704..1ad588fa 100644
--- a/lib/l10n/app_ar.arb
+++ b/lib/l10n/app_ar.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "صفحة العنوان",
+ "pdfIncludeTitlePage": "تضمين صفحة العنوان",
+ "pdfTitlePageNote": "لا تحتوي هذه الصفحة على رأس أو تذييل أو رقم صفحة ظاهر.",
+ "pdfTitlePageTitle": "العنوان",
+ "pdfTitlePageSubtitle": "العنوان الفرعي",
+ "pdfTitlePageAuthor": "المؤلف",
+ "pdfTitlePageOrganization": "المؤسسة",
+ "pdfTitlePageVersion": "الإصدار",
+ "pdfTitlePageDate": "التاريخ",
+ "pdfTitlePageTitleRequired": "أدخل عنوانًا لصفحة العنوان.",
+ "pdfTocSectionNote": "يبدأ في صفحة منفصلة وقد يمتد إلى عدة صفحات.",
"sourceSearchZeroLengthUnsupported": "لا تُدعم مطابقات التعبير النمطي ذات الطول الصفري (مثل ^ أو $ أو الاستباق الذي يطابق موضعًا فقط).",
"workspaceSearchIncomplete": "تظهر بعض النتائج فقط. اعرض المزيد أو ضيّق نطاق البحث.",
"workspaceSearchShowMore": "عرض المزيد من النتائج",
diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb
index f4eac6d5..b4380d5f 100644
--- a/lib/l10n/app_de.arb
+++ b/lib/l10n/app_de.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "Titelseite",
+ "pdfIncludeTitlePage": "Titelseite einschließen",
+ "pdfTitlePageNote": "Diese Seite hat keine Kopfzeile, Fußzeile oder sichtbare Seitenzahl.",
+ "pdfTitlePageTitle": "Titel",
+ "pdfTitlePageSubtitle": "Untertitel",
+ "pdfTitlePageAuthor": "Autor",
+ "pdfTitlePageOrganization": "Organisation",
+ "pdfTitlePageVersion": "Version",
+ "pdfTitlePageDate": "Datum",
+ "pdfTitlePageTitleRequired": "Geben Sie einen Titel für die Titelseite ein.",
+ "pdfTocSectionNote": "Beginnt auf einer separaten Seite und kann mehrere Seiten umfassen.",
"sourceSearchZeroLengthUnsupported": "Regex-Treffer ohne Zeichen werden nicht unterstützt (z. B. ^, $ oder Lookaheads, die nur eine Position treffen).",
"workspaceSearchIncomplete": "Es wird nur ein Teil der Ergebnisse angezeigt. Zeigen Sie mehr an oder grenzen Sie die Suche ein.",
"workspaceSearchShowMore": "Weitere Ergebnisse anzeigen",
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index c82c96a9..a7acff47 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -1,4 +1,26 @@
{
+ "pdfTitlePage": "Title page",
+ "@pdfTitlePage": {"description": "PDF-specific title-page settings group."},
+ "pdfIncludeTitlePage": "Include title page",
+ "@pdfIncludeTitlePage": {"description": "Toggle for a generated PDF title page, disabled by default."},
+ "pdfTitlePageNote": "This page has no running header, footer, or visible page number.",
+ "@pdfTitlePageNote": {"description": "Explains that a PDF title page always suppresses running text and its visible number."},
+ "pdfTitlePageTitle": "Title",
+ "@pdfTitlePageTitle": {"description": "Required plain-text title for the current PDF export's title page."},
+ "pdfTitlePageSubtitle": "Subtitle",
+ "@pdfTitlePageSubtitle": {"description": "Optional plain-text title-page subtitle; may contain multiple lines."},
+ "pdfTitlePageAuthor": "Author",
+ "@pdfTitlePageAuthor": {"description": "Optional author display text on the current PDF title page."},
+ "pdfTitlePageOrganization": "Organization",
+ "@pdfTitlePageOrganization": {"description": "Optional organization display text on the current PDF title page."},
+ "pdfTitlePageVersion": "Version",
+ "@pdfTitlePageVersion": {"description": "Optional free-form version display text, not the BusyMark application version."},
+ "pdfTitlePageDate": "Date",
+ "@pdfTitlePageDate": {"description": "Optional free-form date display text; no particular date format is required."},
+ "pdfTitlePageTitleRequired": "Enter a title for the title page.",
+ "@pdfTitlePageTitleRequired": {"description": "Validation message when the enabled PDF title page has an empty title."},
+ "pdfTocSectionNote": "Starts on a separate page and may span multiple pages.",
+ "@pdfTocSectionNote": {"description": "PDF-only explanation that a native table of contents occupies its own section."},
"@@locale": "en",
"appTitle": "BusyMark",
"@appTitle": {"description": "Application name."},
diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb
index 71ede500..7c9ea5e9 100644
--- a/lib/l10n/app_es.arb
+++ b/lib/l10n/app_es.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "Portada",
+ "pdfIncludeTitlePage": "Incluir portada",
+ "pdfTitlePageNote": "Esta página no tiene encabezado, pie de página ni número de página visible.",
+ "pdfTitlePageTitle": "Título",
+ "pdfTitlePageSubtitle": "Subtítulo",
+ "pdfTitlePageAuthor": "Autor",
+ "pdfTitlePageOrganization": "Organización",
+ "pdfTitlePageVersion": "Versión",
+ "pdfTitlePageDate": "Fecha",
+ "pdfTitlePageTitleRequired": "Introduzca un título para la portada.",
+ "pdfTocSectionNote": "Comienza en una página independiente y puede ocupar varias páginas.",
"sourceSearchZeroLengthUnsupported": "No se admiten coincidencias de longitud cero (por ejemplo, ^, $ o anticipaciones que solo coinciden con una posición).",
"workspaceSearchIncomplete": "Solo se muestran algunos resultados. Muestre más o limite la búsqueda.",
"workspaceSearchShowMore": "Mostrar más resultados",
diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb
index 655b304e..444e6369 100644
--- a/lib/l10n/app_et.arb
+++ b/lib/l10n/app_et.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "Tiitelleht",
+ "pdfIncludeTitlePage": "Lisa tiitelleht",
+ "pdfTitlePageNote": "Sellel lehel pole päist, jalust ega nähtavat leheküljenumbrit.",
+ "pdfTitlePageTitle": "Pealkiri",
+ "pdfTitlePageSubtitle": "Alapealkiri",
+ "pdfTitlePageAuthor": "Autor",
+ "pdfTitlePageOrganization": "Organisatsioon",
+ "pdfTitlePageVersion": "Versioon",
+ "pdfTitlePageDate": "Kuupäev",
+ "pdfTitlePageTitleRequired": "Sisesta tiitellehe pealkiri.",
+ "pdfTocSectionNote": "Algab eraldi lehelt ja võib hõlmata mitut lehte.",
"sourceSearchZeroLengthUnsupported": "Nullpikkusega regulaaravaldiste vasteid ei toetata (näiteks ^, $ või ainult asukohta sobitavad ettevaated).",
"workspaceSearchIncomplete": "Kuvatakse ainult osa tulemustest. Kuva rohkem või kitsenda otsingut.",
"workspaceSearchShowMore": "Kuva rohkem tulemusi",
diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb
index 1edfe04e..6c913f01 100644
--- a/lib/l10n/app_fa.arb
+++ b/lib/l10n/app_fa.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "صفحهٔ عنوان",
+ "pdfIncludeTitlePage": "افزودن صفحهٔ عنوان",
+ "pdfTitlePageNote": "این صفحه سربرگ، پابرگ یا شمارهٔ صفحهٔ قابل مشاهده ندارد.",
+ "pdfTitlePageTitle": "عنوان",
+ "pdfTitlePageSubtitle": "زیرعنوان",
+ "pdfTitlePageAuthor": "نویسنده",
+ "pdfTitlePageOrganization": "سازمان",
+ "pdfTitlePageVersion": "نسخه",
+ "pdfTitlePageDate": "تاریخ",
+ "pdfTitlePageTitleRequired": "عنوانی برای صفحهٔ عنوان وارد کنید.",
+ "pdfTocSectionNote": "از صفحهای جداگانه شروع میشود و ممکن است چند صفحه ادامه یابد.",
"sourceSearchZeroLengthUnsupported": "تطبیقهای با طول صفر در عبارت منظم پشتیبانی نمیشوند (مانند ^، $ یا نگاهبهجلو که فقط با یک موقعیت تطبیق دارد).",
"workspaceSearchIncomplete": "فقط بخشی از نتایج نمایش داده میشود. نتایج بیشتری نمایش دهید یا جستجو را محدود کنید.",
"workspaceSearchShowMore": "نمایش نتایج بیشتر",
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index 7fbda41e..e8764803 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "Page de titre",
+ "pdfIncludeTitlePage": "Inclure une page de titre",
+ "pdfTitlePageNote": "Cette page ne comporte ni en-tête, ni pied de page, ni numéro de page visible.",
+ "pdfTitlePageTitle": "Titre",
+ "pdfTitlePageSubtitle": "Sous-titre",
+ "pdfTitlePageAuthor": "Auteur",
+ "pdfTitlePageOrganization": "Organisation",
+ "pdfTitlePageVersion": "Version",
+ "pdfTitlePageDate": "Date",
+ "pdfTitlePageTitleRequired": "Saisissez un titre pour la page de titre.",
+ "pdfTocSectionNote": "Commence sur une page distincte et peut occuper plusieurs pages.",
"sourceSearchZeroLengthUnsupported": "Les correspondances de longueur nulle ne sont pas prises en charge (par exemple ^, $ ou les assertions qui ne correspondent qu’à une position).",
"workspaceSearchIncomplete": "Seule une partie des résultats est affichée. Affichez-en davantage ou affinez la recherche.",
"workspaceSearchShowMore": "Afficher plus de résultats",
diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb
index 8ab7c306..832121b2 100644
--- a/lib/l10n/app_hi.arb
+++ b/lib/l10n/app_hi.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "शीर्षक पृष्ठ",
+ "pdfIncludeTitlePage": "शीर्षक पृष्ठ शामिल करें",
+ "pdfTitlePageNote": "इस पृष्ठ पर शीर्षलेख, पादलेख या पृष्ठ संख्या नहीं दिखती है।",
+ "pdfTitlePageTitle": "शीर्षक",
+ "pdfTitlePageSubtitle": "उपशीर्षक",
+ "pdfTitlePageAuthor": "लेखक",
+ "pdfTitlePageOrganization": "संगठन",
+ "pdfTitlePageVersion": "संस्करण",
+ "pdfTitlePageDate": "तारीख",
+ "pdfTitlePageTitleRequired": "शीर्षक पृष्ठ के लिए शीर्षक दर्ज करें।",
+ "pdfTocSectionNote": "अलग पृष्ठ से शुरू होता है और कई पृष्ठों तक जा सकता है।",
"sourceSearchZeroLengthUnsupported": "शून्य लंबाई वाले रेगेक्स मिलान समर्थित नहीं हैं (जैसे ^, $ या केवल स्थिति से मेल खाने वाले लुकअहेड)।",
"workspaceSearchIncomplete": "केवल कुछ परिणाम दिखाए गए हैं। अधिक परिणाम देखें या खोज को सीमित करें।",
"workspaceSearchShowMore": "अधिक परिणाम दिखाएँ",
diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb
index b2535bdc..79ade7f0 100644
--- a/lib/l10n/app_id.arb
+++ b/lib/l10n/app_id.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "Halaman judul",
+ "pdfIncludeTitlePage": "Sertakan halaman judul",
+ "pdfTitlePageNote": "Halaman ini tidak memiliki header, footer, atau nomor halaman yang terlihat.",
+ "pdfTitlePageTitle": "Judul",
+ "pdfTitlePageSubtitle": "Subjudul",
+ "pdfTitlePageAuthor": "Penulis",
+ "pdfTitlePageOrganization": "Organisasi",
+ "pdfTitlePageVersion": "Versi",
+ "pdfTitlePageDate": "Tanggal",
+ "pdfTitlePageTitleRequired": "Masukkan judul untuk halaman judul.",
+ "pdfTocSectionNote": "Dimulai pada halaman terpisah dan dapat mencakup beberapa halaman.",
"sourceSearchZeroLengthUnsupported": "Kecocokan regex dengan panjang nol tidak didukung (misalnya ^, $, atau lookahead yang hanya cocok dengan posisi).",
"workspaceSearchIncomplete": "Hanya sebagian hasil yang ditampilkan. Tampilkan lebih banyak atau persempit pencarian.",
"workspaceSearchShowMore": "Tampilkan lebih banyak hasil",
diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb
index e4cab1bd..017c05f0 100644
--- a/lib/l10n/app_it.arb
+++ b/lib/l10n/app_it.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "Frontespizio",
+ "pdfIncludeTitlePage": "Includi frontespizio",
+ "pdfTitlePageNote": "Questa pagina non ha intestazione, piè di pagina o numero di pagina visibile.",
+ "pdfTitlePageTitle": "Titolo",
+ "pdfTitlePageSubtitle": "Sottotitolo",
+ "pdfTitlePageAuthor": "Autore",
+ "pdfTitlePageOrganization": "Organizzazione",
+ "pdfTitlePageVersion": "Versione",
+ "pdfTitlePageDate": "Data",
+ "pdfTitlePageTitleRequired": "Inserisci un titolo per il frontespizio.",
+ "pdfTocSectionNote": "Inizia su una pagina separata e può occupare più pagine.",
"sourceSearchZeroLengthUnsupported": "Le corrispondenze regex di lunghezza zero non sono supportate (ad esempio ^, $ o lookahead che corrispondono solo a una posizione).",
"workspaceSearchIncomplete": "È mostrata solo una parte dei risultati. Mostrane altri o restringi la ricerca.",
"workspaceSearchShowMore": "Mostra altri risultati",
diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb
index c0906e97..2106dfeb 100644
--- a/lib/l10n/app_ja.arb
+++ b/lib/l10n/app_ja.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "タイトルページ",
+ "pdfIncludeTitlePage": "タイトルページを含める",
+ "pdfTitlePageNote": "このページにはヘッダー、フッター、ページ番号は表示されません。",
+ "pdfTitlePageTitle": "タイトル",
+ "pdfTitlePageSubtitle": "サブタイトル",
+ "pdfTitlePageAuthor": "著者",
+ "pdfTitlePageOrganization": "組織",
+ "pdfTitlePageVersion": "バージョン",
+ "pdfTitlePageDate": "日付",
+ "pdfTitlePageTitleRequired": "タイトルページのタイトルを入力してください。",
+ "pdfTocSectionNote": "別のページから始まり、複数ページにわたる場合があります。",
"sourceSearchZeroLengthUnsupported": "長さがゼロの正規表現の一致には対応していません(^、$、位置のみに一致する先読みなど)。",
"workspaceSearchIncomplete": "一部の結果のみ表示しています。さらに表示するか、検索を絞り込んでください。",
"workspaceSearchShowMore": "さらに結果を表示",
diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb
index 2d097145..1030ff5d 100644
--- a/lib/l10n/app_ko.arb
+++ b/lib/l10n/app_ko.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "제목 페이지",
+ "pdfIncludeTitlePage": "제목 페이지 포함",
+ "pdfTitlePageNote": "이 페이지에는 머리글, 바닥글 또는 페이지 번호가 표시되지 않습니다.",
+ "pdfTitlePageTitle": "제목",
+ "pdfTitlePageSubtitle": "부제",
+ "pdfTitlePageAuthor": "작성자",
+ "pdfTitlePageOrganization": "조직",
+ "pdfTitlePageVersion": "버전",
+ "pdfTitlePageDate": "날짜",
+ "pdfTitlePageTitleRequired": "제목 페이지의 제목을 입력하세요.",
+ "pdfTocSectionNote": "별도 페이지에서 시작하며 여러 페이지에 걸칠 수 있습니다.",
"sourceSearchZeroLengthUnsupported": "길이가 0인 정규식 일치는 지원하지 않습니다(예: ^, $, 위치에만 일치하는 전방 탐색).",
"workspaceSearchIncomplete": "일부 결과만 표시됩니다. 더 표시하거나 검색 범위를 좁히세요.",
"workspaceSearchShowMore": "결과 더 보기",
diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb
index 2a40bf80..6939d0d8 100644
--- a/lib/l10n/app_nb.arb
+++ b/lib/l10n/app_nb.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "Tittelside",
+ "pdfIncludeTitlePage": "Ta med tittelside",
+ "pdfTitlePageNote": "Denne siden har ingen topptekst, bunntekst eller synlig sidetall.",
+ "pdfTitlePageTitle": "Tittel",
+ "pdfTitlePageSubtitle": "Undertittel",
+ "pdfTitlePageAuthor": "Forfatter",
+ "pdfTitlePageOrganization": "Organisasjon",
+ "pdfTitlePageVersion": "Versjon",
+ "pdfTitlePageDate": "Dato",
+ "pdfTitlePageTitleRequired": "Skriv inn en tittel for tittelsiden.",
+ "pdfTocSectionNote": "Starter på en egen side og kan gå over flere sider.",
"sourceSearchZeroLengthUnsupported": "Regex-treff med null lengde støttes ikke (for eksempel ^, $ eller fremoversøk som bare samsvarer med en posisjon).",
"workspaceSearchIncomplete": "Bare noen av resultatene vises. Vis flere eller avgrens søket.",
"workspaceSearchShowMore": "Vis flere resultater",
diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb
index ddd73fe1..5471aa44 100644
--- a/lib/l10n/app_nl.arb
+++ b/lib/l10n/app_nl.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "Titelpagina",
+ "pdfIncludeTitlePage": "Titelpagina opnemen",
+ "pdfTitlePageNote": "Deze pagina heeft geen koptekst, voettekst of zichtbaar paginanummer.",
+ "pdfTitlePageTitle": "Titel",
+ "pdfTitlePageSubtitle": "Ondertitel",
+ "pdfTitlePageAuthor": "Auteur",
+ "pdfTitlePageOrganization": "Organisatie",
+ "pdfTitlePageVersion": "Versie",
+ "pdfTitlePageDate": "Datum",
+ "pdfTitlePageTitleRequired": "Voer een titel voor de titelpagina in.",
+ "pdfTocSectionNote": "Begint op een aparte pagina en kan meerdere pagina’s beslaan.",
"sourceSearchZeroLengthUnsupported": "Regex-overeenkomsten met lengte nul worden niet ondersteund (zoals ^, $ of lookaheads die alleen een positie vinden).",
"workspaceSearchIncomplete": "Slechts een deel van de resultaten wordt getoond. Toon meer of verfijn de zoekopdracht.",
"workspaceSearchShowMore": "Meer resultaten tonen",
diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb
index bb55ee60..a07f5916 100644
--- a/lib/l10n/app_pl.arb
+++ b/lib/l10n/app_pl.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "Strona tytułowa",
+ "pdfIncludeTitlePage": "Dołącz stronę tytułową",
+ "pdfTitlePageNote": "Ta strona nie zawiera nagłówka, stopki ani widocznego numeru strony.",
+ "pdfTitlePageTitle": "Tytuł",
+ "pdfTitlePageSubtitle": "Podtytuł",
+ "pdfTitlePageAuthor": "Autor",
+ "pdfTitlePageOrganization": "Organizacja",
+ "pdfTitlePageVersion": "Wersja",
+ "pdfTitlePageDate": "Data",
+ "pdfTitlePageTitleRequired": "Wpisz tytuł strony tytułowej.",
+ "pdfTocSectionNote": "Zaczyna się na osobnej stronie i może zajmować wiele stron.",
"sourceSearchZeroLengthUnsupported": "Dopasowania wyrażeń regularnych o zerowej długości nie są obsługiwane (np. ^, $ lub asercje dopasowujące tylko pozycję).",
"workspaceSearchIncomplete": "Wyświetlono tylko część wyników. Pokaż więcej lub zawęź wyszukiwanie.",
"workspaceSearchShowMore": "Pokaż więcej wyników",
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index 9287c2a0..cea8ffdd 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "Página de título",
+ "pdfIncludeTitlePage": "Incluir página de título",
+ "pdfTitlePageNote": "Esta página não tem cabeçalho, rodapé nem número de página visível.",
+ "pdfTitlePageTitle": "Título",
+ "pdfTitlePageSubtitle": "Subtítulo",
+ "pdfTitlePageAuthor": "Autor",
+ "pdfTitlePageOrganization": "Organização",
+ "pdfTitlePageVersion": "Versão",
+ "pdfTitlePageDate": "Data",
+ "pdfTitlePageTitleRequired": "Introduza um título para a página de título.",
+ "pdfTocSectionNote": "Começa numa página separada e pode ocupar várias páginas.",
"sourceSearchZeroLengthUnsupported": "Não são suportadas correspondências regex de comprimento zero (por exemplo, ^, $ ou antecipações que correspondem apenas a uma posição).",
"workspaceSearchIncomplete": "Só são apresentados alguns resultados. Mostre mais ou restrinja a pesquisa.",
"workspaceSearchShowMore": "Mostrar mais resultados",
diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb
index 7edb8da6..f55acc93 100644
--- a/lib/l10n/app_pt_BR.arb
+++ b/lib/l10n/app_pt_BR.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "Página de título",
+ "pdfIncludeTitlePage": "Incluir página de título",
+ "pdfTitlePageNote": "Esta página não tem cabeçalho, rodapé nem número de página visível.",
+ "pdfTitlePageTitle": "Título",
+ "pdfTitlePageSubtitle": "Subtítulo",
+ "pdfTitlePageAuthor": "Autor",
+ "pdfTitlePageOrganization": "Organização",
+ "pdfTitlePageVersion": "Versão",
+ "pdfTitlePageDate": "Data",
+ "pdfTitlePageTitleRequired": "Insira um título para a página de título.",
+ "pdfTocSectionNote": "Começa em uma página separada e pode ocupar várias páginas.",
"sourceSearchZeroLengthUnsupported": "Não há suporte para correspondências regex de comprimento zero (por exemplo, ^, $ ou antecipações que correspondem apenas a uma posição).",
"workspaceSearchIncomplete": "Apenas alguns resultados são exibidos. Mostre mais ou restrinja a pesquisa.",
"workspaceSearchShowMore": "Mostrar mais resultados",
diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb
index 77a33d5b..7662d59c 100644
--- a/lib/l10n/app_ru.arb
+++ b/lib/l10n/app_ru.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "Титульная страница",
+ "pdfIncludeTitlePage": "Добавить титульную страницу",
+ "pdfTitlePageNote": "На этой странице нет колонтитулов и видимого номера страницы.",
+ "pdfTitlePageTitle": "Заголовок",
+ "pdfTitlePageSubtitle": "Подзаголовок",
+ "pdfTitlePageAuthor": "Автор",
+ "pdfTitlePageOrganization": "Организация",
+ "pdfTitlePageVersion": "Версия",
+ "pdfTitlePageDate": "Дата",
+ "pdfTitlePageTitleRequired": "Введите заголовок титульной страницы.",
+ "pdfTocSectionNote": "Начинается с отдельной страницы и может занимать несколько страниц.",
"sourceSearchZeroLengthUnsupported": "Совпадения регулярных выражений нулевой длины не поддерживаются (например, ^, $ или проверки вперёд, совпадающие только с позицией).",
"workspaceSearchIncomplete": "Показана только часть результатов. Покажите больше или уточните поиск.",
"workspaceSearchShowMore": "Показать больше результатов",
diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb
index 36e0713a..2d6330c6 100644
--- a/lib/l10n/app_tr.arb
+++ b/lib/l10n/app_tr.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "Başlık sayfası",
+ "pdfIncludeTitlePage": "Başlık sayfası ekle",
+ "pdfTitlePageNote": "Bu sayfada üstbilgi, altbilgi veya görünür sayfa numarası bulunmaz.",
+ "pdfTitlePageTitle": "Başlık",
+ "pdfTitlePageSubtitle": "Alt başlık",
+ "pdfTitlePageAuthor": "Yazar",
+ "pdfTitlePageOrganization": "Kuruluş",
+ "pdfTitlePageVersion": "Sürüm",
+ "pdfTitlePageDate": "Tarih",
+ "pdfTitlePageTitleRequired": "Başlık sayfası için bir başlık girin.",
+ "pdfTocSectionNote": "Ayrı bir sayfada başlar ve birden fazla sayfa sürebilir.",
"sourceSearchZeroLengthUnsupported": "Sıfır uzunluklu düzenli ifade eşleşmeleri desteklenmez (örneğin ^, $ veya yalnızca bir konumla eşleşen ileri bakışlar).",
"workspaceSearchIncomplete": "Sonuçların yalnızca bir kısmı gösteriliyor. Daha fazlasını gösterin veya aramayı daraltın.",
"workspaceSearchShowMore": "Daha fazla sonuç göster",
diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb
index 3e627dec..8b327d31 100644
--- a/lib/l10n/app_uk.arb
+++ b/lib/l10n/app_uk.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "Титульна сторінка",
+ "pdfIncludeTitlePage": "Додати титульну сторінку",
+ "pdfTitlePageNote": "На цій сторінці немає колонтитулів і видимого номера сторінки.",
+ "pdfTitlePageTitle": "Заголовок",
+ "pdfTitlePageSubtitle": "Підзаголовок",
+ "pdfTitlePageAuthor": "Автор",
+ "pdfTitlePageOrganization": "Організація",
+ "pdfTitlePageVersion": "Версія",
+ "pdfTitlePageDate": "Дата",
+ "pdfTitlePageTitleRequired": "Введіть заголовок титульної сторінки.",
+ "pdfTocSectionNote": "Починається з окремої сторінки й може займати кілька сторінок.",
"sourceSearchZeroLengthUnsupported": "Збіги регулярних виразів нульової довжини не підтримуються (наприклад, ^, $ або перевірки вперед, що збігаються лише з позицією).",
"workspaceSearchIncomplete": "Показано лише частину результатів. Покажіть більше або звузьте пошук.",
"workspaceSearchShowMore": "Показати більше результатів",
diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb
index 47ccd5d4..def0820e 100644
--- a/lib/l10n/app_vi.arb
+++ b/lib/l10n/app_vi.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "Trang tiêu đề",
+ "pdfIncludeTitlePage": "Bao gồm trang tiêu đề",
+ "pdfTitlePageNote": "Trang này không có đầu trang, chân trang hoặc số trang hiển thị.",
+ "pdfTitlePageTitle": "Tiêu đề",
+ "pdfTitlePageSubtitle": "Phụ đề",
+ "pdfTitlePageAuthor": "Tác giả",
+ "pdfTitlePageOrganization": "Tổ chức",
+ "pdfTitlePageVersion": "Phiên bản",
+ "pdfTitlePageDate": "Ngày",
+ "pdfTitlePageTitleRequired": "Nhập tiêu đề cho trang tiêu đề.",
+ "pdfTocSectionNote": "Bắt đầu trên trang riêng và có thể kéo dài nhiều trang.",
"sourceSearchZeroLengthUnsupported": "Không hỗ trợ kết quả khớp regex có độ dài bằng 0 (ví dụ ^, $ hoặc lookahead chỉ khớp với một vị trí).",
"workspaceSearchIncomplete": "Chỉ hiển thị một phần kết quả. Hiển thị thêm hoặc thu hẹp tìm kiếm.",
"workspaceSearchShowMore": "Hiển thị thêm kết quả",
diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb
index 5929bcf8..e9a5a6d7 100644
--- a/lib/l10n/app_zh.arb
+++ b/lib/l10n/app_zh.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "标题页",
+ "pdfIncludeTitlePage": "包含标题页",
+ "pdfTitlePageNote": "此页不显示页眉、页脚或页码。",
+ "pdfTitlePageTitle": "标题",
+ "pdfTitlePageSubtitle": "副标题",
+ "pdfTitlePageAuthor": "作者",
+ "pdfTitlePageOrganization": "组织",
+ "pdfTitlePageVersion": "版本",
+ "pdfTitlePageDate": "日期",
+ "pdfTitlePageTitleRequired": "请输入标题页的标题。",
+ "pdfTocSectionNote": "从单独一页开始,可以跨多页。",
"sourceSearchZeroLengthUnsupported": "不支持零长度的正则表达式匹配(例如 ^、$ 或仅匹配位置的前瞻断言)。",
"workspaceSearchIncomplete": "仅显示了部分结果。请显示更多结果或缩小搜索范围。",
"workspaceSearchShowMore": "显示更多结果",
diff --git a/lib/l10n/app_zh_CN.arb b/lib/l10n/app_zh_CN.arb
index 760dfa9b..c64e30c6 100644
--- a/lib/l10n/app_zh_CN.arb
+++ b/lib/l10n/app_zh_CN.arb
@@ -1,4 +1,15 @@
{
+ "pdfTitlePage": "标题页",
+ "pdfIncludeTitlePage": "包含标题页",
+ "pdfTitlePageNote": "此页不显示页眉、页脚或页码。",
+ "pdfTitlePageTitle": "标题",
+ "pdfTitlePageSubtitle": "副标题",
+ "pdfTitlePageAuthor": "作者",
+ "pdfTitlePageOrganization": "组织",
+ "pdfTitlePageVersion": "版本",
+ "pdfTitlePageDate": "日期",
+ "pdfTitlePageTitleRequired": "请输入标题页的标题。",
+ "pdfTocSectionNote": "从单独一页开始,可以跨多页。",
"sourceSearchZeroLengthUnsupported": "不支持零长度的正则表达式匹配(例如 ^、$ 或仅匹配位置的前瞻断言)。",
"workspaceSearchIncomplete": "仅显示了部分结果。请显示更多结果或缩小搜索范围。",
"workspaceSearchShowMore": "显示更多结果",
diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart
index 74ee4668..1af1f5c7 100644
--- a/lib/l10n/generated/app_localizations.dart
+++ b/lib/l10n/generated/app_localizations.dart
@@ -139,6 +139,72 @@ abstract class AppLocalizations {
Locale('zh', 'CN'),
];
+ /// PDF-specific title-page settings group.
+ ///
+ /// In en, this message translates to:
+ /// **'Title page'**
+ String get pdfTitlePage;
+
+ /// Toggle for a generated PDF title page, disabled by default.
+ ///
+ /// In en, this message translates to:
+ /// **'Include title page'**
+ String get pdfIncludeTitlePage;
+
+ /// Explains that a PDF title page always suppresses running text and its visible number.
+ ///
+ /// In en, this message translates to:
+ /// **'This page has no running header, footer, or visible page number.'**
+ String get pdfTitlePageNote;
+
+ /// Required plain-text title for the current PDF export's title page.
+ ///
+ /// In en, this message translates to:
+ /// **'Title'**
+ String get pdfTitlePageTitle;
+
+ /// Optional plain-text title-page subtitle; may contain multiple lines.
+ ///
+ /// In en, this message translates to:
+ /// **'Subtitle'**
+ String get pdfTitlePageSubtitle;
+
+ /// Optional author display text on the current PDF title page.
+ ///
+ /// In en, this message translates to:
+ /// **'Author'**
+ String get pdfTitlePageAuthor;
+
+ /// Optional organization display text on the current PDF title page.
+ ///
+ /// In en, this message translates to:
+ /// **'Organization'**
+ String get pdfTitlePageOrganization;
+
+ /// Optional free-form version display text, not the BusyMark application version.
+ ///
+ /// In en, this message translates to:
+ /// **'Version'**
+ String get pdfTitlePageVersion;
+
+ /// Optional free-form date display text; no particular date format is required.
+ ///
+ /// In en, this message translates to:
+ /// **'Date'**
+ String get pdfTitlePageDate;
+
+ /// Validation message when the enabled PDF title page has an empty title.
+ ///
+ /// In en, this message translates to:
+ /// **'Enter a title for the title page.'**
+ String get pdfTitlePageTitleRequired;
+
+ /// PDF-only explanation that a native table of contents occupies its own section.
+ ///
+ /// In en, this message translates to:
+ /// **'Starts on a separate page and may span multiple pages.'**
+ String get pdfTocSectionNote;
+
/// Application name.
///
/// In en, this message translates to:
diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart
index c3a7b289..8000d323 100644
--- a/lib/l10n/generated/app_localizations_ar.dart
+++ b/lib/l10n/generated/app_localizations_ar.dart
@@ -10,6 +10,40 @@ import 'app_localizations.dart';
class AppLocalizationsAr extends AppLocalizations {
AppLocalizationsAr([String locale = 'ar']) : super(locale);
+ @override
+ String get pdfTitlePage => 'صفحة العنوان';
+
+ @override
+ String get pdfIncludeTitlePage => 'تضمين صفحة العنوان';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'لا تحتوي هذه الصفحة على رأس أو تذييل أو رقم صفحة ظاهر.';
+
+ @override
+ String get pdfTitlePageTitle => 'العنوان';
+
+ @override
+ String get pdfTitlePageSubtitle => 'العنوان الفرعي';
+
+ @override
+ String get pdfTitlePageAuthor => 'المؤلف';
+
+ @override
+ String get pdfTitlePageOrganization => 'المؤسسة';
+
+ @override
+ String get pdfTitlePageVersion => 'الإصدار';
+
+ @override
+ String get pdfTitlePageDate => 'التاريخ';
+
+ @override
+ String get pdfTitlePageTitleRequired => 'أدخل عنوانًا لصفحة العنوان.';
+
+ @override
+ String get pdfTocSectionNote => 'يبدأ في صفحة منفصلة وقد يمتد إلى عدة صفحات.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart
index 18d2b9b1..a9a015b5 100644
--- a/lib/l10n/generated/app_localizations_de.dart
+++ b/lib/l10n/generated/app_localizations_de.dart
@@ -10,6 +10,42 @@ import 'app_localizations.dart';
class AppLocalizationsDe extends AppLocalizations {
AppLocalizationsDe([String locale = 'de']) : super(locale);
+ @override
+ String get pdfTitlePage => 'Titelseite';
+
+ @override
+ String get pdfIncludeTitlePage => 'Titelseite einschließen';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'Diese Seite hat keine Kopfzeile, Fußzeile oder sichtbare Seitenzahl.';
+
+ @override
+ String get pdfTitlePageTitle => 'Titel';
+
+ @override
+ String get pdfTitlePageSubtitle => 'Untertitel';
+
+ @override
+ String get pdfTitlePageAuthor => 'Autor';
+
+ @override
+ String get pdfTitlePageOrganization => 'Organisation';
+
+ @override
+ String get pdfTitlePageVersion => 'Version';
+
+ @override
+ String get pdfTitlePageDate => 'Datum';
+
+ @override
+ String get pdfTitlePageTitleRequired =>
+ 'Geben Sie einen Titel für die Titelseite ein.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'Beginnt auf einer separaten Seite und kann mehrere Seiten umfassen.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart
index 5382e3d7..e2e99d9a 100644
--- a/lib/l10n/generated/app_localizations_en.dart
+++ b/lib/l10n/generated/app_localizations_en.dart
@@ -10,6 +10,41 @@ import 'app_localizations.dart';
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
+ @override
+ String get pdfTitlePage => 'Title page';
+
+ @override
+ String get pdfIncludeTitlePage => 'Include title page';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'This page has no running header, footer, or visible page number.';
+
+ @override
+ String get pdfTitlePageTitle => 'Title';
+
+ @override
+ String get pdfTitlePageSubtitle => 'Subtitle';
+
+ @override
+ String get pdfTitlePageAuthor => 'Author';
+
+ @override
+ String get pdfTitlePageOrganization => 'Organization';
+
+ @override
+ String get pdfTitlePageVersion => 'Version';
+
+ @override
+ String get pdfTitlePageDate => 'Date';
+
+ @override
+ String get pdfTitlePageTitleRequired => 'Enter a title for the title page.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'Starts on a separate page and may span multiple pages.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart
index 7f22ea0a..3ecdadc0 100644
--- a/lib/l10n/generated/app_localizations_es.dart
+++ b/lib/l10n/generated/app_localizations_es.dart
@@ -10,6 +10,42 @@ import 'app_localizations.dart';
class AppLocalizationsEs extends AppLocalizations {
AppLocalizationsEs([String locale = 'es']) : super(locale);
+ @override
+ String get pdfTitlePage => 'Portada';
+
+ @override
+ String get pdfIncludeTitlePage => 'Incluir portada';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'Esta página no tiene encabezado, pie de página ni número de página visible.';
+
+ @override
+ String get pdfTitlePageTitle => 'Título';
+
+ @override
+ String get pdfTitlePageSubtitle => 'Subtítulo';
+
+ @override
+ String get pdfTitlePageAuthor => 'Autor';
+
+ @override
+ String get pdfTitlePageOrganization => 'Organización';
+
+ @override
+ String get pdfTitlePageVersion => 'Versión';
+
+ @override
+ String get pdfTitlePageDate => 'Fecha';
+
+ @override
+ String get pdfTitlePageTitleRequired =>
+ 'Introduzca un título para la portada.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'Comienza en una página independiente y puede ocupar varias páginas.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart
index 6cbb1541..11489004 100644
--- a/lib/l10n/generated/app_localizations_et.dart
+++ b/lib/l10n/generated/app_localizations_et.dart
@@ -10,6 +10,41 @@ import 'app_localizations.dart';
class AppLocalizationsEt extends AppLocalizations {
AppLocalizationsEt([String locale = 'et']) : super(locale);
+ @override
+ String get pdfTitlePage => 'Tiitelleht';
+
+ @override
+ String get pdfIncludeTitlePage => 'Lisa tiitelleht';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'Sellel lehel pole päist, jalust ega nähtavat leheküljenumbrit.';
+
+ @override
+ String get pdfTitlePageTitle => 'Pealkiri';
+
+ @override
+ String get pdfTitlePageSubtitle => 'Alapealkiri';
+
+ @override
+ String get pdfTitlePageAuthor => 'Autor';
+
+ @override
+ String get pdfTitlePageOrganization => 'Organisatsioon';
+
+ @override
+ String get pdfTitlePageVersion => 'Versioon';
+
+ @override
+ String get pdfTitlePageDate => 'Kuupäev';
+
+ @override
+ String get pdfTitlePageTitleRequired => 'Sisesta tiitellehe pealkiri.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'Algab eraldi lehelt ja võib hõlmata mitut lehte.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart
index 403d1538..e4bc18c9 100644
--- a/lib/l10n/generated/app_localizations_fa.dart
+++ b/lib/l10n/generated/app_localizations_fa.dart
@@ -10,6 +10,41 @@ import 'app_localizations.dart';
class AppLocalizationsFa extends AppLocalizations {
AppLocalizationsFa([String locale = 'fa']) : super(locale);
+ @override
+ String get pdfTitlePage => 'صفحهٔ عنوان';
+
+ @override
+ String get pdfIncludeTitlePage => 'افزودن صفحهٔ عنوان';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'این صفحه سربرگ، پابرگ یا شمارهٔ صفحهٔ قابل مشاهده ندارد.';
+
+ @override
+ String get pdfTitlePageTitle => 'عنوان';
+
+ @override
+ String get pdfTitlePageSubtitle => 'زیرعنوان';
+
+ @override
+ String get pdfTitlePageAuthor => 'نویسنده';
+
+ @override
+ String get pdfTitlePageOrganization => 'سازمان';
+
+ @override
+ String get pdfTitlePageVersion => 'نسخه';
+
+ @override
+ String get pdfTitlePageDate => 'تاریخ';
+
+ @override
+ String get pdfTitlePageTitleRequired => 'عنوانی برای صفحهٔ عنوان وارد کنید.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'از صفحهای جداگانه شروع میشود و ممکن است چند صفحه ادامه یابد.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart
index 0a349c0a..16ba3ee7 100644
--- a/lib/l10n/generated/app_localizations_fr.dart
+++ b/lib/l10n/generated/app_localizations_fr.dart
@@ -10,6 +10,42 @@ import 'app_localizations.dart';
class AppLocalizationsFr extends AppLocalizations {
AppLocalizationsFr([String locale = 'fr']) : super(locale);
+ @override
+ String get pdfTitlePage => 'Page de titre';
+
+ @override
+ String get pdfIncludeTitlePage => 'Inclure une page de titre';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'Cette page ne comporte ni en-tête, ni pied de page, ni numéro de page visible.';
+
+ @override
+ String get pdfTitlePageTitle => 'Titre';
+
+ @override
+ String get pdfTitlePageSubtitle => 'Sous-titre';
+
+ @override
+ String get pdfTitlePageAuthor => 'Auteur';
+
+ @override
+ String get pdfTitlePageOrganization => 'Organisation';
+
+ @override
+ String get pdfTitlePageVersion => 'Version';
+
+ @override
+ String get pdfTitlePageDate => 'Date';
+
+ @override
+ String get pdfTitlePageTitleRequired =>
+ 'Saisissez un titre pour la page de titre.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'Commence sur une page distincte et peut occuper plusieurs pages.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart
index 8a41f375..75c873b3 100644
--- a/lib/l10n/generated/app_localizations_hi.dart
+++ b/lib/l10n/generated/app_localizations_hi.dart
@@ -10,6 +10,42 @@ import 'app_localizations.dart';
class AppLocalizationsHi extends AppLocalizations {
AppLocalizationsHi([String locale = 'hi']) : super(locale);
+ @override
+ String get pdfTitlePage => 'शीर्षक पृष्ठ';
+
+ @override
+ String get pdfIncludeTitlePage => 'शीर्षक पृष्ठ शामिल करें';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'इस पृष्ठ पर शीर्षलेख, पादलेख या पृष्ठ संख्या नहीं दिखती है।';
+
+ @override
+ String get pdfTitlePageTitle => 'शीर्षक';
+
+ @override
+ String get pdfTitlePageSubtitle => 'उपशीर्षक';
+
+ @override
+ String get pdfTitlePageAuthor => 'लेखक';
+
+ @override
+ String get pdfTitlePageOrganization => 'संगठन';
+
+ @override
+ String get pdfTitlePageVersion => 'संस्करण';
+
+ @override
+ String get pdfTitlePageDate => 'तारीख';
+
+ @override
+ String get pdfTitlePageTitleRequired =>
+ 'शीर्षक पृष्ठ के लिए शीर्षक दर्ज करें।';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'अलग पृष्ठ से शुरू होता है और कई पृष्ठों तक जा सकता है।';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart
index 926dbe68..f6aaaece 100644
--- a/lib/l10n/generated/app_localizations_id.dart
+++ b/lib/l10n/generated/app_localizations_id.dart
@@ -10,6 +10,41 @@ import 'app_localizations.dart';
class AppLocalizationsId extends AppLocalizations {
AppLocalizationsId([String locale = 'id']) : super(locale);
+ @override
+ String get pdfTitlePage => 'Halaman judul';
+
+ @override
+ String get pdfIncludeTitlePage => 'Sertakan halaman judul';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'Halaman ini tidak memiliki header, footer, atau nomor halaman yang terlihat.';
+
+ @override
+ String get pdfTitlePageTitle => 'Judul';
+
+ @override
+ String get pdfTitlePageSubtitle => 'Subjudul';
+
+ @override
+ String get pdfTitlePageAuthor => 'Penulis';
+
+ @override
+ String get pdfTitlePageOrganization => 'Organisasi';
+
+ @override
+ String get pdfTitlePageVersion => 'Versi';
+
+ @override
+ String get pdfTitlePageDate => 'Tanggal';
+
+ @override
+ String get pdfTitlePageTitleRequired => 'Masukkan judul untuk halaman judul.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'Dimulai pada halaman terpisah dan dapat mencakup beberapa halaman.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart
index a48c6de5..eb39509e 100644
--- a/lib/l10n/generated/app_localizations_it.dart
+++ b/lib/l10n/generated/app_localizations_it.dart
@@ -10,6 +10,42 @@ import 'app_localizations.dart';
class AppLocalizationsIt extends AppLocalizations {
AppLocalizationsIt([String locale = 'it']) : super(locale);
+ @override
+ String get pdfTitlePage => 'Frontespizio';
+
+ @override
+ String get pdfIncludeTitlePage => 'Includi frontespizio';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'Questa pagina non ha intestazione, piè di pagina o numero di pagina visibile.';
+
+ @override
+ String get pdfTitlePageTitle => 'Titolo';
+
+ @override
+ String get pdfTitlePageSubtitle => 'Sottotitolo';
+
+ @override
+ String get pdfTitlePageAuthor => 'Autore';
+
+ @override
+ String get pdfTitlePageOrganization => 'Organizzazione';
+
+ @override
+ String get pdfTitlePageVersion => 'Versione';
+
+ @override
+ String get pdfTitlePageDate => 'Data';
+
+ @override
+ String get pdfTitlePageTitleRequired =>
+ 'Inserisci un titolo per il frontespizio.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'Inizia su una pagina separata e può occupare più pagine.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart
index b7d5094e..f69d95fb 100644
--- a/lib/l10n/generated/app_localizations_ja.dart
+++ b/lib/l10n/generated/app_localizations_ja.dart
@@ -10,6 +10,39 @@ import 'app_localizations.dart';
class AppLocalizationsJa extends AppLocalizations {
AppLocalizationsJa([String locale = 'ja']) : super(locale);
+ @override
+ String get pdfTitlePage => 'タイトルページ';
+
+ @override
+ String get pdfIncludeTitlePage => 'タイトルページを含める';
+
+ @override
+ String get pdfTitlePageNote => 'このページにはヘッダー、フッター、ページ番号は表示されません。';
+
+ @override
+ String get pdfTitlePageTitle => 'タイトル';
+
+ @override
+ String get pdfTitlePageSubtitle => 'サブタイトル';
+
+ @override
+ String get pdfTitlePageAuthor => '著者';
+
+ @override
+ String get pdfTitlePageOrganization => '組織';
+
+ @override
+ String get pdfTitlePageVersion => 'バージョン';
+
+ @override
+ String get pdfTitlePageDate => '日付';
+
+ @override
+ String get pdfTitlePageTitleRequired => 'タイトルページのタイトルを入力してください。';
+
+ @override
+ String get pdfTocSectionNote => '別のページから始まり、複数ページにわたる場合があります。';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart
index 5020fd71..e36ecf67 100644
--- a/lib/l10n/generated/app_localizations_ko.dart
+++ b/lib/l10n/generated/app_localizations_ko.dart
@@ -10,6 +10,39 @@ import 'app_localizations.dart';
class AppLocalizationsKo extends AppLocalizations {
AppLocalizationsKo([String locale = 'ko']) : super(locale);
+ @override
+ String get pdfTitlePage => '제목 페이지';
+
+ @override
+ String get pdfIncludeTitlePage => '제목 페이지 포함';
+
+ @override
+ String get pdfTitlePageNote => '이 페이지에는 머리글, 바닥글 또는 페이지 번호가 표시되지 않습니다.';
+
+ @override
+ String get pdfTitlePageTitle => '제목';
+
+ @override
+ String get pdfTitlePageSubtitle => '부제';
+
+ @override
+ String get pdfTitlePageAuthor => '작성자';
+
+ @override
+ String get pdfTitlePageOrganization => '조직';
+
+ @override
+ String get pdfTitlePageVersion => '버전';
+
+ @override
+ String get pdfTitlePageDate => '날짜';
+
+ @override
+ String get pdfTitlePageTitleRequired => '제목 페이지의 제목을 입력하세요.';
+
+ @override
+ String get pdfTocSectionNote => '별도 페이지에서 시작하며 여러 페이지에 걸칠 수 있습니다.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_nb.dart b/lib/l10n/generated/app_localizations_nb.dart
index 05ccbee8..cc4e4e95 100644
--- a/lib/l10n/generated/app_localizations_nb.dart
+++ b/lib/l10n/generated/app_localizations_nb.dart
@@ -10,6 +10,42 @@ import 'app_localizations.dart';
class AppLocalizationsNb extends AppLocalizations {
AppLocalizationsNb([String locale = 'nb']) : super(locale);
+ @override
+ String get pdfTitlePage => 'Tittelside';
+
+ @override
+ String get pdfIncludeTitlePage => 'Ta med tittelside';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'Denne siden har ingen topptekst, bunntekst eller synlig sidetall.';
+
+ @override
+ String get pdfTitlePageTitle => 'Tittel';
+
+ @override
+ String get pdfTitlePageSubtitle => 'Undertittel';
+
+ @override
+ String get pdfTitlePageAuthor => 'Forfatter';
+
+ @override
+ String get pdfTitlePageOrganization => 'Organisasjon';
+
+ @override
+ String get pdfTitlePageVersion => 'Versjon';
+
+ @override
+ String get pdfTitlePageDate => 'Dato';
+
+ @override
+ String get pdfTitlePageTitleRequired =>
+ 'Skriv inn en tittel for tittelsiden.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'Starter på en egen side og kan gå over flere sider.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart
index ec88b5aa..33f6bfd2 100644
--- a/lib/l10n/generated/app_localizations_nl.dart
+++ b/lib/l10n/generated/app_localizations_nl.dart
@@ -10,6 +10,42 @@ import 'app_localizations.dart';
class AppLocalizationsNl extends AppLocalizations {
AppLocalizationsNl([String locale = 'nl']) : super(locale);
+ @override
+ String get pdfTitlePage => 'Titelpagina';
+
+ @override
+ String get pdfIncludeTitlePage => 'Titelpagina opnemen';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'Deze pagina heeft geen koptekst, voettekst of zichtbaar paginanummer.';
+
+ @override
+ String get pdfTitlePageTitle => 'Titel';
+
+ @override
+ String get pdfTitlePageSubtitle => 'Ondertitel';
+
+ @override
+ String get pdfTitlePageAuthor => 'Auteur';
+
+ @override
+ String get pdfTitlePageOrganization => 'Organisatie';
+
+ @override
+ String get pdfTitlePageVersion => 'Versie';
+
+ @override
+ String get pdfTitlePageDate => 'Datum';
+
+ @override
+ String get pdfTitlePageTitleRequired =>
+ 'Voer een titel voor de titelpagina in.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'Begint op een aparte pagina en kan meerdere pagina’s beslaan.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart
index 401758c7..3ece7ff9 100644
--- a/lib/l10n/generated/app_localizations_pl.dart
+++ b/lib/l10n/generated/app_localizations_pl.dart
@@ -10,6 +10,41 @@ import 'app_localizations.dart';
class AppLocalizationsPl extends AppLocalizations {
AppLocalizationsPl([String locale = 'pl']) : super(locale);
+ @override
+ String get pdfTitlePage => 'Strona tytułowa';
+
+ @override
+ String get pdfIncludeTitlePage => 'Dołącz stronę tytułową';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'Ta strona nie zawiera nagłówka, stopki ani widocznego numeru strony.';
+
+ @override
+ String get pdfTitlePageTitle => 'Tytuł';
+
+ @override
+ String get pdfTitlePageSubtitle => 'Podtytuł';
+
+ @override
+ String get pdfTitlePageAuthor => 'Autor';
+
+ @override
+ String get pdfTitlePageOrganization => 'Organizacja';
+
+ @override
+ String get pdfTitlePageVersion => 'Wersja';
+
+ @override
+ String get pdfTitlePageDate => 'Data';
+
+ @override
+ String get pdfTitlePageTitleRequired => 'Wpisz tytuł strony tytułowej.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'Zaczyna się na osobnej stronie i może zajmować wiele stron.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart
index c6d679a0..173388b1 100644
--- a/lib/l10n/generated/app_localizations_pt.dart
+++ b/lib/l10n/generated/app_localizations_pt.dart
@@ -10,6 +10,42 @@ import 'app_localizations.dart';
class AppLocalizationsPt extends AppLocalizations {
AppLocalizationsPt([String locale = 'pt']) : super(locale);
+ @override
+ String get pdfTitlePage => 'Página de título';
+
+ @override
+ String get pdfIncludeTitlePage => 'Incluir página de título';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'Esta página não tem cabeçalho, rodapé nem número de página visível.';
+
+ @override
+ String get pdfTitlePageTitle => 'Título';
+
+ @override
+ String get pdfTitlePageSubtitle => 'Subtítulo';
+
+ @override
+ String get pdfTitlePageAuthor => 'Autor';
+
+ @override
+ String get pdfTitlePageOrganization => 'Organização';
+
+ @override
+ String get pdfTitlePageVersion => 'Versão';
+
+ @override
+ String get pdfTitlePageDate => 'Data';
+
+ @override
+ String get pdfTitlePageTitleRequired =>
+ 'Introduza um título para a página de título.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'Começa numa página separada e pode ocupar várias páginas.';
+
@override
String get appTitle => 'BusyMark';
@@ -4258,6 +4294,42 @@ class AppLocalizationsPt extends AppLocalizations {
class AppLocalizationsPtBr extends AppLocalizationsPt {
AppLocalizationsPtBr() : super('pt_BR');
+ @override
+ String get pdfTitlePage => 'Página de título';
+
+ @override
+ String get pdfIncludeTitlePage => 'Incluir página de título';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'Esta página não tem cabeçalho, rodapé nem número de página visível.';
+
+ @override
+ String get pdfTitlePageTitle => 'Título';
+
+ @override
+ String get pdfTitlePageSubtitle => 'Subtítulo';
+
+ @override
+ String get pdfTitlePageAuthor => 'Autor';
+
+ @override
+ String get pdfTitlePageOrganization => 'Organização';
+
+ @override
+ String get pdfTitlePageVersion => 'Versão';
+
+ @override
+ String get pdfTitlePageDate => 'Data';
+
+ @override
+ String get pdfTitlePageTitleRequired =>
+ 'Insira um título para a página de título.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'Começa em uma página separada e pode ocupar várias páginas.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart
index 513224ce..f38fae5f 100644
--- a/lib/l10n/generated/app_localizations_ru.dart
+++ b/lib/l10n/generated/app_localizations_ru.dart
@@ -10,6 +10,42 @@ import 'app_localizations.dart';
class AppLocalizationsRu extends AppLocalizations {
AppLocalizationsRu([String locale = 'ru']) : super(locale);
+ @override
+ String get pdfTitlePage => 'Титульная страница';
+
+ @override
+ String get pdfIncludeTitlePage => 'Добавить титульную страницу';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'На этой странице нет колонтитулов и видимого номера страницы.';
+
+ @override
+ String get pdfTitlePageTitle => 'Заголовок';
+
+ @override
+ String get pdfTitlePageSubtitle => 'Подзаголовок';
+
+ @override
+ String get pdfTitlePageAuthor => 'Автор';
+
+ @override
+ String get pdfTitlePageOrganization => 'Организация';
+
+ @override
+ String get pdfTitlePageVersion => 'Версия';
+
+ @override
+ String get pdfTitlePageDate => 'Дата';
+
+ @override
+ String get pdfTitlePageTitleRequired =>
+ 'Введите заголовок титульной страницы.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'Начинается с отдельной страницы и может занимать несколько страниц.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart
index c7e6ab7e..d7478790 100644
--- a/lib/l10n/generated/app_localizations_tr.dart
+++ b/lib/l10n/generated/app_localizations_tr.dart
@@ -10,6 +10,42 @@ import 'app_localizations.dart';
class AppLocalizationsTr extends AppLocalizations {
AppLocalizationsTr([String locale = 'tr']) : super(locale);
+ @override
+ String get pdfTitlePage => 'Başlık sayfası';
+
+ @override
+ String get pdfIncludeTitlePage => 'Başlık sayfası ekle';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'Bu sayfada üstbilgi, altbilgi veya görünür sayfa numarası bulunmaz.';
+
+ @override
+ String get pdfTitlePageTitle => 'Başlık';
+
+ @override
+ String get pdfTitlePageSubtitle => 'Alt başlık';
+
+ @override
+ String get pdfTitlePageAuthor => 'Yazar';
+
+ @override
+ String get pdfTitlePageOrganization => 'Kuruluş';
+
+ @override
+ String get pdfTitlePageVersion => 'Sürüm';
+
+ @override
+ String get pdfTitlePageDate => 'Tarih';
+
+ @override
+ String get pdfTitlePageTitleRequired =>
+ 'Başlık sayfası için bir başlık girin.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'Ayrı bir sayfada başlar ve birden fazla sayfa sürebilir.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_uk.dart b/lib/l10n/generated/app_localizations_uk.dart
index 5b1790d9..633a32cc 100644
--- a/lib/l10n/generated/app_localizations_uk.dart
+++ b/lib/l10n/generated/app_localizations_uk.dart
@@ -10,6 +10,42 @@ import 'app_localizations.dart';
class AppLocalizationsUk extends AppLocalizations {
AppLocalizationsUk([String locale = 'uk']) : super(locale);
+ @override
+ String get pdfTitlePage => 'Титульна сторінка';
+
+ @override
+ String get pdfIncludeTitlePage => 'Додати титульну сторінку';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'На цій сторінці немає колонтитулів і видимого номера сторінки.';
+
+ @override
+ String get pdfTitlePageTitle => 'Заголовок';
+
+ @override
+ String get pdfTitlePageSubtitle => 'Підзаголовок';
+
+ @override
+ String get pdfTitlePageAuthor => 'Автор';
+
+ @override
+ String get pdfTitlePageOrganization => 'Організація';
+
+ @override
+ String get pdfTitlePageVersion => 'Версія';
+
+ @override
+ String get pdfTitlePageDate => 'Дата';
+
+ @override
+ String get pdfTitlePageTitleRequired =>
+ 'Введіть заголовок титульної сторінки.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'Починається з окремої сторінки й може займати кілька сторінок.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart
index 68003a57..488f2185 100644
--- a/lib/l10n/generated/app_localizations_vi.dart
+++ b/lib/l10n/generated/app_localizations_vi.dart
@@ -10,6 +10,41 @@ import 'app_localizations.dart';
class AppLocalizationsVi extends AppLocalizations {
AppLocalizationsVi([String locale = 'vi']) : super(locale);
+ @override
+ String get pdfTitlePage => 'Trang tiêu đề';
+
+ @override
+ String get pdfIncludeTitlePage => 'Bao gồm trang tiêu đề';
+
+ @override
+ String get pdfTitlePageNote =>
+ 'Trang này không có đầu trang, chân trang hoặc số trang hiển thị.';
+
+ @override
+ String get pdfTitlePageTitle => 'Tiêu đề';
+
+ @override
+ String get pdfTitlePageSubtitle => 'Phụ đề';
+
+ @override
+ String get pdfTitlePageAuthor => 'Tác giả';
+
+ @override
+ String get pdfTitlePageOrganization => 'Tổ chức';
+
+ @override
+ String get pdfTitlePageVersion => 'Phiên bản';
+
+ @override
+ String get pdfTitlePageDate => 'Ngày';
+
+ @override
+ String get pdfTitlePageTitleRequired => 'Nhập tiêu đề cho trang tiêu đề.';
+
+ @override
+ String get pdfTocSectionNote =>
+ 'Bắt đầu trên trang riêng và có thể kéo dài nhiều trang.';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart
index 2c7851cc..495d6472 100644
--- a/lib/l10n/generated/app_localizations_zh.dart
+++ b/lib/l10n/generated/app_localizations_zh.dart
@@ -10,6 +10,39 @@ import 'app_localizations.dart';
class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
+ @override
+ String get pdfTitlePage => '标题页';
+
+ @override
+ String get pdfIncludeTitlePage => '包含标题页';
+
+ @override
+ String get pdfTitlePageNote => '此页不显示页眉、页脚或页码。';
+
+ @override
+ String get pdfTitlePageTitle => '标题';
+
+ @override
+ String get pdfTitlePageSubtitle => '副标题';
+
+ @override
+ String get pdfTitlePageAuthor => '作者';
+
+ @override
+ String get pdfTitlePageOrganization => '组织';
+
+ @override
+ String get pdfTitlePageVersion => '版本';
+
+ @override
+ String get pdfTitlePageDate => '日期';
+
+ @override
+ String get pdfTitlePageTitleRequired => '请输入标题页的标题。';
+
+ @override
+ String get pdfTocSectionNote => '从单独一页开始,可以跨多页。';
+
@override
String get appTitle => 'BusyMark';
@@ -4008,6 +4041,39 @@ class AppLocalizationsZh extends AppLocalizations {
class AppLocalizationsZhCn extends AppLocalizationsZh {
AppLocalizationsZhCn() : super('zh_CN');
+ @override
+ String get pdfTitlePage => '标题页';
+
+ @override
+ String get pdfIncludeTitlePage => '包含标题页';
+
+ @override
+ String get pdfTitlePageNote => '此页不显示页眉、页脚或页码。';
+
+ @override
+ String get pdfTitlePageTitle => '标题';
+
+ @override
+ String get pdfTitlePageSubtitle => '副标题';
+
+ @override
+ String get pdfTitlePageAuthor => '作者';
+
+ @override
+ String get pdfTitlePageOrganization => '组织';
+
+ @override
+ String get pdfTitlePageVersion => '版本';
+
+ @override
+ String get pdfTitlePageDate => '日期';
+
+ @override
+ String get pdfTitlePageTitleRequired => '请输入标题页的标题。';
+
+ @override
+ String get pdfTocSectionNote => '从单独一页开始,可以跨多页。';
+
@override
String get appTitle => 'BusyMark';
diff --git a/lib/src/app/app_metadata.dart b/lib/src/app/app_metadata.dart
index e3a1300e..92d33207 100644
--- a/lib/src/app/app_metadata.dart
+++ b/lib/src/app/app_metadata.dart
@@ -1 +1 @@
-const busyMarkAppVersion = '0.4.0';
+const busyMarkAppVersion = '0.4.1';
diff --git a/lib/src/app/busymark_app.dart b/lib/src/app/busymark_app.dart
index bc24bdf3..59bc75ec 100644
--- a/lib/src/app/busymark_app.dart
+++ b/lib/src/app/busymark_app.dart
@@ -884,6 +884,9 @@ class BusyMarkApp extends ConsumerWidget {
back: material.backButtonTooltip,
backShortcut: shortcut(BusyMarkCommandIds.back),
save: label(BusyMarkCommandIds.save),
+ export: label(BusyMarkCommandIds.export),
+ exportShortcut: shortcut(BusyMarkCommandIds.export),
+ exportGtkAccelerator: accelerator(BusyMarkCommandIds.export),
fullScreen: label(BusyMarkCommandIds.fullScreen),
fullScreenShortcut: shortcut(BusyMarkCommandIds.fullScreen),
fullScreenGtkAccelerator: accelerator(BusyMarkCommandIds.fullScreen),
@@ -909,6 +912,8 @@ class BusyMarkApp extends ConsumerWidget {
searchQuery: '',
textDirection: textDirection,
canRefresh: false,
+ canExportPdf: false,
+ canExportHtml: false,
documentControlsVisible: false,
searchActive: false,
searchVisible: false,
diff --git a/lib/src/app/busymark_main_menu.dart b/lib/src/app/busymark_main_menu.dart
index b7190eea..61ca9be9 100644
--- a/lib/src/app/busymark_main_menu.dart
+++ b/lib/src/app/busymark_main_menu.dart
@@ -10,6 +10,7 @@ import 'localization.dart';
import 'window_control_service.dart';
enum BusyMarkMainMenuAction {
+ export,
fullScreen,
settings,
keyboardShortcuts,
@@ -20,9 +21,14 @@ enum BusyMarkMainMenuAction {
}
class BusyMarkMainMenuButton extends ConsumerWidget {
- const BusyMarkMainMenuButton({super.key, required this.onSelected});
+ const BusyMarkMainMenuButton({
+ super.key,
+ required this.onSelected,
+ this.canExport = false,
+ });
final ValueChanged onSelected;
+ final bool canExport;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -38,6 +44,13 @@ class BusyMarkMainMenuButton extends ConsumerWidget {
tooltip: l10n.mainMenu,
icon: BusyMarkGlyphs.menuVertical,
itemBuilder: (context) => [
+ BusyMarkPopupMenuItem(
+ value: BusyMarkMainMenuAction.export,
+ label: command(BusyMarkCommandIds.export).label(context),
+ icon: BusyMarkGlyphs.exportPdf,
+ shortcut: command(BusyMarkCommandIds.export).shortcut?.label,
+ enabled: canExport,
+ ),
BusyMarkPopupMenuItem(
value: BusyMarkMainMenuAction.fullScreen,
label: command(BusyMarkCommandIds.fullScreen).label(context),
@@ -86,6 +99,7 @@ class BusyMarkMainMenuButton extends ConsumerWidget {
],
onSelected: (action) {
final commandId = switch (action) {
+ BusyMarkMainMenuAction.export => BusyMarkCommandIds.export,
BusyMarkMainMenuAction.fullScreen => BusyMarkCommandIds.fullScreen,
BusyMarkMainMenuAction.settings => BusyMarkCommandIds.settings,
BusyMarkMainMenuAction.keyboardShortcuts =>
diff --git a/lib/src/assets/asset_ingestion_service.dart b/lib/src/assets/asset_ingestion_service.dart
index d5d6d7fb..97710bbb 100644
--- a/lib/src/assets/asset_ingestion_service.dart
+++ b/lib/src/assets/asset_ingestion_service.dart
@@ -65,6 +65,13 @@ class IngestedAsset {
final AssetIngestionOrigin origin;
}
+class IngestedAssetSnapshot {
+ const IngestedAssetSnapshot({required this.asset, required this.bytes});
+
+ final IngestedAsset asset;
+ final Uint8List bytes;
+}
+
class AssetIngestionService {
const AssetIngestionService({this.maximumAssetBytes = 100 * 1024 * 1024});
@@ -74,6 +81,22 @@ class AssetIngestionService {
required String sourcePath,
required AssetIngestionRequest request,
required AssetIngestionOrigin origin,
+ }) async {
+ return (await ingestFileSnapshot(
+ sourcePath: sourcePath,
+ request: request,
+ origin: origin,
+ )).asset;
+ }
+
+ /// Ingests one immutable read of [sourcePath] and returns that exact read.
+ ///
+ /// Callers that need to retain or otherwise reuse the inserted content must
+ /// use [bytes] instead of reopening a path that may have changed meanwhile.
+ Future ingestFileSnapshot({
+ required String sourcePath,
+ required AssetIngestionRequest request,
+ required AssetIngestionOrigin origin,
}) async {
final source = File(p.normalize(p.absolute(sourcePath)));
final stat = await source.stat();
@@ -89,12 +112,14 @@ class AssetIngestionService {
'The image is empty or exceeds the supported size limit.',
);
}
- return ingestBytes(
- bytes: await source.readAsBytes(),
+ final bytes = await source.readAsBytes();
+ final asset = await ingestBytes(
+ bytes: bytes,
suggestedFileName: p.basename(source.path),
request: request,
origin: origin,
);
+ return IngestedAssetSnapshot(asset: asset, bytes: bytes);
}
Future ingestBytes({
@@ -103,22 +128,82 @@ class AssetIngestionService {
required AssetIngestionRequest request,
required AssetIngestionOrigin origin,
}) async {
+ _validateBytes(bytes, request: request);
+ final imageType = _detectImageType(bytes);
+ if (imageType == null) {
+ throw const AssetIngestionException(
+ 'asset.invalid-image-type',
+ 'The selected file is not a supported PNG, JPEG, GIF, WebP, or SVG image.',
+ );
+ }
+ return _publishBytes(
+ bytes: bytes,
+ suggestedFileName: suggestedFileName,
+ request: request,
+ origin: origin,
+ type: imageType,
+ );
+ }
+
+ /// Restores media captured from a rich clipboard fragment.
+ ///
+ /// Rich fragments can own both images and local Writerside videos. Ordinary
+ /// image ingestion remains image-only; this broader boundary is reserved for
+ /// restoring those already-captured media snapshots.
+ Future ingestMediaBytes({
+ required Uint8List bytes,
+ required String suggestedFileName,
+ required AssetIngestionRequest request,
+ required AssetIngestionOrigin origin,
+ }) async {
+ _validateBytes(bytes, request: request);
+ final type = _detectImageType(bytes) ?? _videoType(suggestedFileName);
+ if (type == null) {
+ throw const AssetIngestionException(
+ 'asset.invalid-media-type',
+ 'The retained file is not a supported image or video.',
+ );
+ }
+ return _publishBytes(
+ bytes: bytes,
+ suggestedFileName: suggestedFileName,
+ request: request,
+ origin: origin,
+ type: type,
+ );
+ }
+
+ bool canIngestMediaBytes({
+ required Uint8List bytes,
+ required String suggestedFileName,
+ }) =>
+ bytes.isNotEmpty &&
+ bytes.length <= maximumAssetBytes &&
+ (_videoType(suggestedFileName) != null ||
+ _detectImageType(bytes) != null);
+
+ void _validateBytes(
+ Uint8List bytes, {
+ required AssetIngestionRequest request,
+ }) {
if (request.documentFilePath.trim().isEmpty) {
throw const AssetSaveRequiredException();
}
if (bytes.isEmpty || bytes.length > maximumAssetBytes) {
throw const AssetIngestionException(
'asset.invalid-size',
- 'The image is empty or exceeds the supported size limit.',
- );
- }
- final imageType = _detectImageType(bytes);
- if (imageType == null) {
- throw const AssetIngestionException(
- 'asset.invalid-image-type',
- 'The selected file is not a supported PNG, JPEG, GIF, WebP, or SVG image.',
+ 'The file is empty or exceeds the supported size limit.',
);
}
+ }
+
+ Future _publishBytes({
+ required Uint8List bytes,
+ required String suggestedFileName,
+ required AssetIngestionRequest request,
+ required AssetIngestionOrigin origin,
+ required _DetectedAssetType type,
+ }) async {
final destination = await _destinationDirectory(request);
final contentHash = sha256.convert(bytes).toString();
final existing = await _identicalAsset(
@@ -135,7 +220,7 @@ class AssetIngestionService {
published = await _publishUnique(
destination,
stem: stem,
- extension: imageType.extension,
+ extension: type.extension,
bytes: bytes,
);
reused = false;
@@ -145,7 +230,7 @@ class AssetIngestionService {
markdownPath: p
.relative(published.path, from: p.dirname(request.documentFilePath))
.replaceAll(p.separator, '/'),
- mimeType: imageType.mimeType,
+ mimeType: type.mimeType,
reusedExisting: reused,
origin: origin,
);
@@ -289,7 +374,7 @@ class AssetIngestionService {
return sanitized.length <= 80 ? sanitized : sanitized.substring(0, 80);
}
- _DetectedImageType? _detectImageType(Uint8List bytes) {
+ _DetectedAssetType? _detectImageType(Uint8List bytes) {
if (bytes.length >= 8 &&
bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
@@ -299,24 +384,24 @@ class AssetIngestionService {
bytes[5] == 0x0a &&
bytes[6] == 0x1a &&
bytes[7] == 0x0a) {
- return const _DetectedImageType('png', 'image/png');
+ return const _DetectedAssetType('png', 'image/png');
}
if (bytes.length >= 3 &&
bytes[0] == 0xff &&
bytes[1] == 0xd8 &&
bytes[2] == 0xff) {
- return const _DetectedImageType('jpg', 'image/jpeg');
+ return const _DetectedAssetType('jpg', 'image/jpeg');
}
if (bytes.length >= 6) {
final signature = ascii.decode(bytes.sublist(0, 6), allowInvalid: true);
if (signature == 'GIF87a' || signature == 'GIF89a') {
- return const _DetectedImageType('gif', 'image/gif');
+ return const _DetectedAssetType('gif', 'image/gif');
}
}
if (bytes.length >= 12 &&
ascii.decode(bytes.sublist(0, 4), allowInvalid: true) == 'RIFF' &&
ascii.decode(bytes.sublist(8, 12), allowInvalid: true) == 'WEBP') {
- return const _DetectedImageType('webp', 'image/webp');
+ return const _DetectedAssetType('webp', 'image/webp');
}
try {
final source = utf8.decode(bytes);
@@ -324,17 +409,29 @@ class AssetIngestionService {
source.startsWith('\uFEFF') ? source.substring(1) : source,
);
if (document.rootElement.name.local.toLowerCase() == 'svg') {
- return const _DetectedImageType('svg', 'image/svg+xml');
+ return const _DetectedAssetType('svg', 'image/svg+xml');
}
} on Object {
// Binary or malformed XML is not SVG.
}
return null;
}
+
+ _DetectedAssetType? _videoType(String suggestedFileName) =>
+ switch (p.extension(suggestedFileName).toLowerCase()) {
+ '.avi' => const _DetectedAssetType('avi', 'video/x-msvideo'),
+ '.m4v' => const _DetectedAssetType('m4v', 'video/x-m4v'),
+ '.mkv' => const _DetectedAssetType('mkv', 'video/x-matroska'),
+ '.mov' => const _DetectedAssetType('mov', 'video/quicktime'),
+ '.mp4' => const _DetectedAssetType('mp4', 'video/mp4'),
+ '.ogv' => const _DetectedAssetType('ogv', 'video/ogg'),
+ '.webm' => const _DetectedAssetType('webm', 'video/webm'),
+ _ => null,
+ };
}
-class _DetectedImageType {
- const _DetectedImageType(this.extension, this.mimeType);
+class _DetectedAssetType {
+ const _DetectedAssetType(this.extension, this.mimeType);
final String extension;
final String mimeType;
diff --git a/lib/src/clipboard/clipboard_history_controller.dart b/lib/src/clipboard/clipboard_history_controller.dart
index e9fe9cce..c3278ca3 100644
--- a/lib/src/clipboard/clipboard_history_controller.dart
+++ b/lib/src/clipboard/clipboard_history_controller.dart
@@ -168,9 +168,7 @@ class ClipboardHistoryController extends Notifier {
if (generation != _refreshGeneration || !ref.mounted) return;
BusyMarkClipboardCapture? capture;
- if (first.richFragment != null ||
- first.sourceText != null ||
- first.html != null) {
+ if (first.richFragment != null || first.html != null) {
capture = BusyMarkClipboardCapture(
kind: BusyMarkClipboardContentKind.richText,
text: first.text,
@@ -182,11 +180,11 @@ class ClipboardHistoryController extends Notifier {
origin: first.origin,
external: !first.sessionOwned,
);
- } else if (first.text != null) {
+ } else if (first.text != null || first.sourceText != null) {
capture = BusyMarkClipboardCapture(
kind: BusyMarkClipboardContentKind.text,
- text: first.text,
- sourceText: first.text,
+ text: first.text ?? first.sourceText,
+ sourceText: first.sourceText ?? first.text,
origin: first.origin,
external: !first.sessionOwned,
);
diff --git a/lib/src/clipboard/clipboard_history_panel.dart b/lib/src/clipboard/clipboard_history_panel.dart
index afba6708..e93f823f 100644
--- a/lib/src/clipboard/clipboard_history_panel.dart
+++ b/lib/src/clipboard/clipboard_history_panel.dart
@@ -213,6 +213,10 @@ class _ClipboardHistoryPanelState extends ConsumerState {
widget.onEscape?.call();
return KeyEventResult.handled;
}
+ // Key events bubble through this Focus even while the search field owns
+ // primary focus. List navigation, activation, and especially deletion must
+ // never consume text-editing keys from that descendant.
+ if (!_listFocusNode.hasPrimaryFocus) return KeyEventResult.ignored;
if (visible.isEmpty) return KeyEventResult.ignored;
final index = visible.indexWhere((entry) => entry.id == _selectedId);
if (event.logicalKey == LogicalKeyboardKey.arrowDown ||
@@ -265,13 +269,14 @@ class _ClipboardHistoryPanelState extends ConsumerState {
BusyMarkClipboardPayload payload, {
required bool plainText,
}) async {
- final result = await ref
- .read(clipboardInsertionRegistryProvider)
- .paste(payload, plainText: plainText);
+ // Both dependencies outlive this sidebar panel. Capture them before the
+ // asynchronous insertion so a successful editor operation can still be
+ // retained if the user switches sidebar tabs while it is in flight.
+ final registry = ref.read(clipboardInsertionRegistryProvider);
+ final history = ref.read(clipboardHistoryControllerProvider.notifier);
+ final result = await registry.paste(payload, plainText: plainText);
if (result == ClipboardPasteResult.inserted && payload.external) {
- ref
- .read(clipboardHistoryControllerProvider.notifier)
- .retainCurrentAfterPaste(payload);
+ history.retainCurrentAfterPaste(payload);
} else if (result != ClipboardPasteResult.inserted && mounted) {
BusyMarkToastOverlay.show(
context,
diff --git a/lib/src/core/diagnostic_localizations.dart b/lib/src/core/diagnostic_localizations.dart
index d3e9147f..887e614b 100644
--- a/lib/src/core/diagnostic_localizations.dart
+++ b/lib/src/core/diagnostic_localizations.dart
@@ -82,6 +82,7 @@ String localizeDiagnostic(BuildContext context, Diagnostic diagnostic) {
'writerside.tree.invalid-xml' ||
'writerside.variables.invalid-xml' ||
'writerside.categories.invalid-xml' ||
+ 'writerside.reference-data.invalid-xml' ||
'writerside.topic.invalid-xml' => l10n.diagnosticInvalidXml(
value('message'),
),
diff --git a/lib/src/core/source_span.dart b/lib/src/core/source_span.dart
index b5af7f25..247c2247 100644
--- a/lib/src/core/source_span.dart
+++ b/lib/src/core/source_span.dart
@@ -1,4 +1,5 @@
import 'dart:math' as math;
+import 'dart:async';
class SourceLocation {
const SourceLocation({
@@ -37,8 +38,9 @@ class SourceSpan {
}) {
final safeStart = startOffset.clamp(0, source.length);
final safeEnd = math.max(safeStart, endOffset.clamp(0, source.length));
- final start = SourceLocationMapper(source).locationForOffset(safeStart);
- final end = SourceLocationMapper(source).locationForOffset(safeEnd);
+ final mapper = SourceLocationMapper.forSource(source);
+ final start = mapper.locationForOffset(safeStart);
+ final end = mapper.locationForOffset(safeEnd);
return SourceSpan(
filePath: filePath,
startOffset: safeStart,
@@ -79,6 +81,23 @@ class SourceSpan {
}
class SourceLocationMapper {
+ static final _zoneKey = Object();
+
+ /// Share location maps across helpers and nested parsers for one parse only.
+ static T withSource(String source, T Function() parse) {
+ if (Zone.current[_zoneKey] != null) return parse();
+ return runZoned(
+ parse,
+ zoneValues: {_zoneKey: {}},
+ );
+ }
+
+ static SourceLocationMapper forSource(String source) {
+ final maps = Zone.current[_zoneKey] as Map?;
+ return maps?.putIfAbsent(source, () => SourceLocationMapper(source)) ??
+ SourceLocationMapper(source);
+ }
+
SourceLocationMapper(String source) : _source = source {
_lineStarts = [0];
for (var i = 0; i < source.length; i++) {
diff --git a/lib/src/editor/source/source_editor.dart b/lib/src/editor/source/source_editor.dart
index f1c30e91..29e8f7b5 100644
--- a/lib/src/editor/source/source_editor.dart
+++ b/lib/src/editor/source/source_editor.dart
@@ -1884,7 +1884,7 @@ class BusyMarkSourceEditorState extends State {
await _deleteUncommittedClipboardAssets(assets);
return null;
}
- final asset = await widget.assetIngestionService.ingestBytes(
+ final asset = await widget.assetIngestionService.ingestMediaBytes(
bytes: bytes,
suggestedFileName: p.basename(entry.value),
request: _assetIngestionRequest,
@@ -2972,7 +2972,22 @@ class _SourceClipboardInsertionTarget
return state.widget.language != SourceSyntaxLanguage.plain &&
payload.imageBytes?.isNotEmpty == true;
}
- if (payload.richFragment != null) return payload.mediaComplete;
+ if (payload.richFragment != null) {
+ final fragment = WysiwygClipboardFragment.decode(payload.richFragment!);
+ if (fragment == null) return payload.preferredSourceText != null;
+ if (fragment.mediaPaths.isNotEmpty) {
+ return payload.mediaComplete &&
+ fragment.mediaPaths.entries.every((entry) {
+ final bytes = payload.mediaBytes[entry.key];
+ return bytes != null &&
+ state.widget.assetIngestionService.canIngestMediaBytes(
+ bytes: bytes,
+ suggestedFileName: p.basename(entry.value),
+ );
+ });
+ }
+ return true;
+ }
return payload.preferredSourceText != null;
}
diff --git a/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart b/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart
index e8f41000..287baaf3 100644
--- a/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart
+++ b/lib/src/editor/wysiwyg/wysiwyg_clipboard_fragment.dart
@@ -187,19 +187,30 @@ class WysiwygClipboardFragment {
attributes: attributes(value.attributes),
children: [for (final child in value.children) inline(child)],
);
- BusyBlock block(BusyBlock value) => BusyBlock(
- id: value.id,
- kind: value.kind,
- inlines: [for (final child in value.inlines) inline(child)],
- children: [for (final child in value.children) block(child)],
- attributes: attributes(value.attributes),
- rawSource: value.rawSource,
- preserveRaw: value.preserveRaw,
- isSourceOnly: value.isSourceOnly,
- isGenerated: value.isGenerated,
- isSourceProtected: value.isSourceProtected,
- dirty: true,
- );
+ BusyBlock block(BusyBlock value) {
+ final rebasedAttributes = attributes(value.attributes);
+ final rawSource = value.kind == BusyBlockKind.video
+ ? _rebaseVideoRawSource(
+ value.rawSource,
+ originalSource: value.attributes['src'],
+ rebasedSource: rebasedAttributes['src'],
+ )
+ : value.rawSource;
+ return BusyBlock(
+ id: value.id,
+ kind: value.kind,
+ inlines: [for (final child in value.inlines) inline(child)],
+ children: [for (final child in value.children) block(child)],
+ attributes: rebasedAttributes,
+ rawSource: rawSource,
+ preserveRaw: value.preserveRaw,
+ isSourceOnly: value.isSourceOnly,
+ isGenerated: value.isGenerated,
+ isSourceProtected: value.isSourceProtected,
+ dirty: true,
+ );
+ }
+
return WysiwygClipboardFragment(
mode: mode,
sourcePath: targetPath.isEmpty ? sourcePath : targetPath,
@@ -231,6 +242,32 @@ class WysiwygClipboardFragment {
}
}
+String? _rebaseVideoRawSource(
+ String? rawSource, {
+ required String? originalSource,
+ required String? rebasedSource,
+}) {
+ if (rawSource == null ||
+ originalSource == null ||
+ rebasedSource == null ||
+ originalSource == rebasedSource) {
+ return rawSource;
+ }
+ final attribute = RegExp(
+ r'''(\ssrc\s*=\s*)(["'])(.*?)\2''',
+ caseSensitive: false,
+ dotAll: true,
+ );
+ return rawSource.replaceFirstMapped(attribute, (match) {
+ final quote = match.group(2)!;
+ final escaped = rebasedSource
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll(quote, quote == '"' ? '"' : ''');
+ return '${match.group(1)}$quote$escaped$quote';
+ });
+}
+
Map _encodeInline(BusyInline inline) => {
'kind': inline.kind.name,
'text': inline.text,
diff --git a/lib/src/editor/wysiwyg/wysiwyg_editor.dart b/lib/src/editor/wysiwyg/wysiwyg_editor.dart
index d717b86c..859452ce 100644
--- a/lib/src/editor/wysiwyg/wysiwyg_editor.dart
+++ b/lib/src/editor/wysiwyg/wysiwyg_editor.dart
@@ -4191,10 +4191,10 @@ class _BusyMarkWysiwygEditorState extends State {
AssetIngestionOrigin.clipboardImageFile,
reportInvalidImage: false,
clipboardTarget: target,
+ onInserted: data.sessionOwned
+ ? null
+ : (bytes) => _retainExternalImageSnapshot(filePath, bytes),
)) {
- if (!data.sessionOwned) {
- unawaited(_retainExternalImageFile(filePath));
- }
return;
}
if (_isClipboardTargetCurrent(target)) {
@@ -4213,8 +4213,9 @@ class _BusyMarkWysiwygEditorState extends State {
clipboardFiles.first,
AssetIngestionOrigin.clipboardImageFile,
clipboardTarget: target,
+ onInserted: (bytes) =>
+ _retainExternalImageSnapshot(clipboardFiles.first, bytes),
)) {
- unawaited(_retainExternalImageFile(clipboardFiles.first));
return;
}
final png = await assetInput.readClipboardImagePng();
@@ -4265,23 +4266,17 @@ class _BusyMarkWysiwygEditorState extends State {
);
}
- Future _retainExternalImageFile(String path) async {
- try {
- final bytes = await File(path).readAsBytes();
- if (!mounted) return;
- widget.onClipboardCaptured?.call(
- BusyMarkClipboardCapture(
- kind: BusyMarkClipboardContentKind.image,
- imageBytes: bytes,
- imageMimeType: _clipboardImageMimeType(path),
- imageDisplayName: p.basename(path),
- origin: _clipboardOrigin,
- external: true,
- ),
- );
- } on FileSystemException {
- // The paste already succeeded. History retention is best effort.
- }
+ void _retainExternalImageSnapshot(String path, Uint8List bytes) {
+ widget.onClipboardCaptured?.call(
+ BusyMarkClipboardCapture(
+ kind: BusyMarkClipboardContentKind.image,
+ imageBytes: bytes,
+ imageMimeType: _clipboardImageMimeType(path),
+ imageDisplayName: p.basename(path),
+ origin: _clipboardOrigin,
+ external: true,
+ ),
+ );
}
bool _pasteStyledClipboardIntoActiveBlock(
@@ -4880,18 +4875,21 @@ class _BusyMarkWysiwygEditorState extends State {
AssetIngestionOrigin origin, {
bool reportInvalidImage = true,
_ClipboardTarget? clipboardTarget,
+ ValueChanged? onInserted,
}) async {
try {
- final asset = await widget.assetIngestionService.ingestFile(
+ final snapshot = await widget.assetIngestionService.ingestFileSnapshot(
sourcePath: sourcePath,
request: _assetIngestionRequest,
origin: origin,
);
- return await _requestAltAndInsertAsset(
- asset,
+ final inserted = await _requestAltAndInsertAsset(
+ snapshot.asset,
suggestedAlt: p.basenameWithoutExtension(sourcePath),
clipboardTarget: clipboardTarget,
);
+ if (inserted) onInserted?.call(snapshot.bytes);
+ return inserted;
} on AssetSaveRequiredException {
widget.onAssetSaveRequired?.call();
return false;
@@ -6394,7 +6392,11 @@ class _BusyMarkWysiwygEditorState extends State {
payload,
captured,
);
- if (prepared == null) return ClipboardPasteResult.staleTarget;
+ if (prepared == null) {
+ return _isClipboardTargetCurrent(captured)
+ ? ClipboardPasteResult.unsupported
+ : ClipboardPasteResult.staleTarget;
+ }
fragment = prepared.fragment;
ingestedAssets = prepared.assets;
}
@@ -6469,7 +6471,7 @@ class _BusyMarkWysiwygEditorState extends State {
await _deleteUncommittedClipboardAssets(assets);
return null;
}
- final asset = await widget.assetIngestionService.ingestBytes(
+ final asset = await widget.assetIngestionService.ingestMediaBytes(
bytes: bytes,
suggestedFileName: p.basename(entry.value),
request: _assetIngestionRequest,
@@ -7018,17 +7020,39 @@ class _WysiwygClipboardInsertionTarget
return switch (payload.kind) {
BusyMarkClipboardContentKind.image =>
payload.imageBytes?.isNotEmpty == true,
- BusyMarkClipboardContentKind.richText =>
- payload.mediaComplete &&
- (payload.richFragment != null ||
- payload.html != null ||
- payload.text != null ||
- payload.sourceText != null),
+ BusyMarkClipboardContentKind.richText => _canPasteRichPayload(payload),
BusyMarkClipboardContentKind.text =>
payload.text != null || payload.sourceText != null,
};
}
+ bool _canPasteRichPayload(BusyMarkClipboardPayload payload) {
+ if (!payload.mediaComplete) return false;
+ final encoded = payload.richFragment;
+ final fragment = encoded == null
+ ? null
+ : WysiwygClipboardFragment.decode(encoded);
+ if (encoded != null && fragment == null) {
+ return payload.html != null ||
+ payload.text != null ||
+ payload.sourceText != null;
+ }
+ if (fragment != null && fragment.mediaPaths.isNotEmpty) {
+ return fragment.mediaPaths.entries.every((entry) {
+ final bytes = payload.mediaBytes[entry.key];
+ return bytes != null &&
+ state.widget.assetIngestionService.canIngestMediaBytes(
+ bytes: bytes,
+ suggestedFileName: p.basename(entry.value),
+ );
+ });
+ }
+ return fragment != null ||
+ payload.html != null ||
+ payload.text != null ||
+ payload.sourceText != null;
+ }
+
@override
Future paste(
BusyMarkClipboardPayload payload, {
diff --git a/lib/src/export/export_options.dart b/lib/src/export/export_options.dart
index 49cc4a2a..e0b82c24 100644
--- a/lib/src/export/export_options.dart
+++ b/lib/src/export/export_options.dart
@@ -147,6 +147,7 @@ class PdfPageGeometry {
class PdfExportOptions {
const PdfExportOptions({
this.content = const ExportContentOptions(),
+ this.includeTitlePage = false,
this.pageSize = PdfPageSize.a4,
this.orientation = PdfOrientation.portrait,
this.margin = PdfMarginPreset.normal,
@@ -163,6 +164,7 @@ class PdfExportOptions {
this.showHeaderFooterOnFirstPage = true,
});
final ExportContentOptions content;
+ final bool includeTitlePage;
final PdfPageSize pageSize;
final PdfOrientation orientation;
final PdfMarginPreset margin;
@@ -247,6 +249,7 @@ class PdfExportOptions {
PdfExportOptions copyWith({
ExportContentOptions? content,
+ bool? includeTitlePage,
PdfPageSize? pageSize,
PdfOrientation? orientation,
PdfMarginPreset? margin,
@@ -263,6 +266,7 @@ class PdfExportOptions {
bool? showHeaderFooterOnFirstPage,
}) => PdfExportOptions(
content: content ?? this.content,
+ includeTitlePage: includeTitlePage ?? this.includeTitlePage,
pageSize: pageSize ?? this.pageSize,
orientation: orientation ?? this.orientation,
margin: margin ?? this.margin,
@@ -281,6 +285,7 @@ class PdfExportOptions {
);
Map toJson() => {
'content': content.toJson(),
+ 'includeTitlePage': includeTitlePage,
'pageSize': pageSize.name,
'orientation': orientation.name,
'margin': margin.name,
@@ -301,6 +306,7 @@ class PdfExportOptions {
const d = PdfExportOptions();
var result = PdfExportOptions(
content: ExportContentOptions.fromJson(j['content']),
+ includeTitlePage: _bool(j['includeTitlePage'], d.includeTitlePage),
pageSize: _enum(PdfPageSize.values, j['pageSize'], d.pageSize),
orientation: _enum(
PdfOrientation.values,
diff --git a/lib/src/export/export_options_editor.dart b/lib/src/export/export_options_editor.dart
index c0d22373..14b4bc21 100644
--- a/lib/src/export/export_options_editor.dart
+++ b/lib/src/export/export_options_editor.dart
@@ -9,14 +9,21 @@ import '../platform/linux_header_bar_service.dart';
import '../writerside/writerside_model.dart';
import 'export_options.dart';
import 'html_export_styles.dart';
+import 'pdf_title_page.dart';
enum ExportFormat { pdf, html }
class ExportOptionsSelection {
- const ExportOptionsSelection({this.pdf, this.html, this.instance});
+ const ExportOptionsSelection({
+ this.pdf,
+ this.html,
+ this.instance,
+ this.titlePage,
+ });
final PdfExportOptions? pdf;
final HtmlExportOptions? html;
final WritersideInstance? instance;
+ final PdfTitlePageData? titlePage;
}
Future showExportOptions(
@@ -27,6 +34,7 @@ Future showExportOptions(
bool canExportHtml = true,
List instances = const [],
String? workspaceRoot,
+ PdfTitlePageData? pdfTitlePageDefaults,
}) async {
assert(canExportPdf || canExportHtml);
final controller = ref.read(appSettingsControllerProvider.notifier);
@@ -45,6 +53,7 @@ Future showExportOptions(
canExportPdf: canExportPdf,
canExportHtml: canExportHtml,
instances: instances,
+ pdfTitlePageDefaults: pdfTitlePageDefaults,
instanceId: workspaceRoot == null
? null
: settings.selectedWritersideInstanceId(workspaceRoot),
@@ -76,6 +85,7 @@ class ExportOptionsDialog extends StatefulWidget {
this.canExportHtml = true,
this.instances = const [],
this.instanceId,
+ this.pdfTitlePageDefaults,
}) : assert(canExportPdf || canExportHtml);
final PdfExportOptions pdf;
final HtmlExportOptions html;
@@ -84,6 +94,7 @@ class ExportOptionsDialog extends StatefulWidget {
final bool canExportHtml;
final List instances;
final String? instanceId;
+ final PdfTitlePageData? pdfTitlePageDefaults;
@override
State createState() => _ExportOptionsDialogState();
}
@@ -98,12 +109,30 @@ class _ExportOptionsDialogState extends State {
var _revision = 0;
var _saving = false;
List _fileErrors = [];
+ final _titlePageEdits = {};
+ PdfTitlePageData? get _titlePage {
+ final defaults = _instance == null
+ ? widget.pdfTitlePageDefaults
+ : PdfTitlePageData.forInstance(_instance!);
+ if (defaults == null) return null;
+ return defaults.copyWith(
+ title: _titlePageEdits['title'],
+ subtitle: _titlePageEdits['subtitle'],
+ author: _titlePageEdits['author'],
+ organization: _titlePageEdits['organization'],
+ version: _titlePageEdits['version'],
+ date: _titlePageEdits['date'],
+ );
+ }
+
List get _issues => [
...switch (_format) {
ExportFormat.pdf => _pdf.validate(),
ExportFormat.html => _html.validate(),
},
..._fileErrors,
+ if (_format == ExportFormat.pdf && _pdf.includeTitlePage)
+ ...?_titlePage?.validate(),
];
ExportFormat _availableInitialFormat() {
@@ -122,6 +151,7 @@ class _ExportOptionsDialogState extends State {
pdf: _format == ExportFormat.pdf ? _pdf : null,
html: _format == ExportFormat.html ? _html : null,
instance: _instance,
+ titlePage: _format == ExportFormat.pdf ? _titlePage : null,
);
setState(() => _saving = true);
try {
@@ -181,6 +211,7 @@ class _ExportOptionsDialogState extends State {
: () => setState(() {
if (_format == ExportFormat.pdf) {
_pdf = const PdfExportOptions();
+ _titlePageEdits.clear();
} else {
_html = const HtmlExportOptions();
}
@@ -202,6 +233,9 @@ class _ExportOptionsDialogState extends State {
key: ValueKey((_format, _revision)),
value: _pdf,
instance: _instanceRow(context),
+ titlePageFields: _titlePage == null
+ ? null
+ : _titlePageFields(context),
onChanged: (value) => setState(() => _pdf = value),
)
else
@@ -214,9 +248,42 @@ class _ExportOptionsDialogState extends State {
_fileErrors = [];
}),
),
+ const SizedBox(
+ key: ValueKey('export-options-bottom-spacing'),
+ height: BusyMarkSpacing.lg,
+ ),
],
);
+ Widget _titlePageFields(BuildContext context) {
+ final l = context.l10n;
+ final values = _titlePage!.toJson();
+ return Column(
+ children: [
+ for (final (field, label) in [
+ ('title', l.pdfTitlePageTitle),
+ ('subtitle', l.pdfTitlePageSubtitle),
+ ('author', l.pdfTitlePageAuthor),
+ ('organization', l.pdfTitlePageOrganization),
+ ('version', l.pdfTitlePageVersion),
+ ('date', l.pdfTitlePageDate),
+ ])
+ BusyMarkGroupedTextEntry(
+ key: ValueKey(('pdf-title-page', field, _revision, _instance?.id)),
+ label: label,
+ initialValue: values[field] ?? '',
+ maxLines: field == 'title' || field == 'subtitle' ? 4 : 2,
+ keyboardType: TextInputType.multiline,
+ errorText: field == 'title' && _titlePage!.validate().isNotEmpty
+ ? l.pdfTitlePageTitleRequired
+ : null,
+ enabled: !_saving,
+ onChanged: (text) => setState(() => _titlePageEdits[field] = text),
+ ),
+ ],
+ );
+ }
+
Widget? _instanceRow(BuildContext context) => _instance == null
? null
: BusyMarkComboRow(
@@ -231,6 +298,7 @@ class _ExportOptionsDialogState extends State {
String exportOptionError(BuildContext context, ExportOptionIssue issue) {
final l = context.l10n;
if (issue.field == 'pageGeometry') return l.exportInvalidGeometry;
+ if (issue.field == 'titlePage.title') return l.pdfTitlePageTitleRequired;
if (issue.field == 'accentColor') return l.exportInvalidColor;
if (issue.field.startsWith('customCss')) return l.exportInvalidCss;
final name = switch (issue.field) {
@@ -255,10 +323,12 @@ class ExportContentOptionsEditor extends StatelessWidget {
required this.value,
required this.onChanged,
this.instance,
+ this.tocSubtitle,
});
final ExportContentOptions value;
final ValueChanged onChanged;
final Widget? instance;
+ final String? tocSubtitle;
@override
Widget build(BuildContext context) => BusyMarkGroupedList(
title: context.l10n.writersidePdfContent,
@@ -267,6 +337,7 @@ class ExportContentOptionsEditor extends StatelessWidget {
if (instance != null) instance!,
BusyMarkSwitchRow(
title: context.l10n.exportToc,
+ subtitle: tocSubtitle,
value: value.includeToc,
onChanged: (v) => onChanged(value.copyWith(includeToc: v)),
),
@@ -293,10 +364,12 @@ class PdfExportOptionsEditor extends StatelessWidget {
required this.value,
required this.onChanged,
this.instance,
+ this.titlePageFields,
});
final PdfExportOptions value;
final ValueChanged onChanged;
final Widget? instance;
+ final Widget? titlePageFields;
@override
Widget build(BuildContext context) {
final l = context.l10n;
@@ -309,7 +382,18 @@ class PdfExportOptionsEditor extends StatelessWidget {
value: o.content,
onChanged: (v) => onChanged(o.copyWith(content: v)),
instance: instance,
+ tocSubtitle: l.pdfTocSectionNote,
),
+ _group(l.pdfTitlePage, [
+ BusyMarkSwitchRow(
+ key: const ValueKey('pdf-include-title-page'),
+ title: l.pdfIncludeTitlePage,
+ subtitle: l.pdfTitlePageNote,
+ value: o.includeTitlePage,
+ onChanged: (v) => onChanged(o.copyWith(includeTitlePage: v)),
+ ),
+ if (o.includeTitlePage && titlePageFields != null) titlePageFields!,
+ ]),
_group(l.writersidePdfPage, [
BusyMarkComboRow(
title: l.pdfPageSize,
diff --git a/lib/src/export/html_document_writer.dart b/lib/src/export/html_document_writer.dart
index 897490e0..e0cc378b 100644
--- a/lib/src/export/html_document_writer.dart
+++ b/lib/src/export/html_document_writer.dart
@@ -63,6 +63,7 @@ class HtmlDocumentWriter {
disclosureLabel;
final List _rules = [];
final Set _emittedIds = {};
+ final Map _headingTargets = {};
int _rawNodes = 0;
void _warn(String code, String message, BusyBlock? block) => warnings.add(
@@ -145,8 +146,12 @@ class HtmlDocumentWriter {
),
],
);
+ // HTML parsing normalizes line endings before CSP checks the style text.
+ // Hash and serialize that same text, including built-in and generated CSS.
final css =
- '$stylesheet\n${HtmlExportStyles.generate(options)}\n${_rules.join('\n')}\n$customCss';
+ '$stylesheet\n${HtmlExportStyles.generate(options)}\n${_rules.join('\n')}\n$customCss'
+ .replaceAll('\r\n', '\n')
+ .replaceAll('\r', '\n');
// Meta CSP cannot enforce frame-ancestors or sandbox. All active source
// content has already been rejected. file: images need an explicit source
// in browsers that assign each local document an opaque origin.
@@ -204,7 +209,9 @@ class HtmlDocumentWriter {
Map _prepareOutline(dom.Element article) {
final result = {};
final elements = {
- for (final h in article.querySelectorAll('h1,h2,h3,h4,h5,h6')) h.id: h,
+ for (final h in article.querySelectorAll('h1,h2,h3,h4,h5,h6'))
+ if ((_headingTargets[h] ?? h.id).isNotEmpty)
+ _headingTargets[h] ?? h.id: h,
};
// Static API headings are added during rich-content preparation. Keep the
// single publication outline in the final document order before numbering.
@@ -348,6 +355,8 @@ class HtmlDocumentWriter {
bool _ordered(BusyBlock b) =>
b.kind == BusyBlockKind.orderedListItem ||
b.attributes['ordered'] == 'true';
+ String _listType(BusyBlock b) =>
+ b.attributes['listType'] ?? (_ordered(b) ? 'decimal' : 'bullet');
Future> _blocks(
List blocks, [
int depth = 0,
@@ -363,8 +372,16 @@ class HtmlDocumentWriter {
final b = blocks[i];
if (_isList(b)) {
final ordered = _ordered(b);
+ final listType = _listType(b);
+ final listId = b.attributes['listId'];
+ final markerHidden = b.attributes['markerHidden'] == 'true';
final list = _el(ordered ? 'ol' : 'ul');
+ if (listType == 'alpha-lower') list.attributes['type'] = 'a';
+ if (listType == 'none' || markerHidden) {
+ _style(list, 'list-style-type:none;');
+ }
final start =
+ int.tryParse(b.attributes['listOrdinal'] ?? '') ??
int.tryParse(
(b.attributes['marker'] ?? '').replaceAll(RegExp(r'[^0-9-]'), ''),
) ??
@@ -372,7 +389,10 @@ class HtmlDocumentWriter {
if (ordered && start != 1) list.attributes['start'] = '$start';
while (i < blocks.length &&
_isList(blocks[i]) &&
- _ordered(blocks[i]) == ordered) {
+ _ordered(blocks[i]) == ordered &&
+ _listType(blocks[i]) == listType &&
+ blocks[i].attributes['listId'] == listId &&
+ (blocks[i].attributes['markerHidden'] == 'true') == markerHidden) {
final item = blocks[i];
final li = _el('li');
_attributes(li, item.attributes);
@@ -446,6 +466,10 @@ class HtmlDocumentWriter {
disclosure: false,
),
);
+ if (b.kind == BusyBlockKind.heading && details.id.isNotEmpty) {
+ final heading = details.querySelector('h1,h2,h3,h4,h5,h6');
+ if (heading != null) _headingTargets[heading] = details.id;
+ }
return [details];
}
if (b.attributes['html-footnotes'] case final source?) {
@@ -489,23 +513,25 @@ class HtmlDocumentWriter {
'${page.filename}:${b.id}',
);
if (result?.reference case final reference?) {
- return [await _reference(reference, b, depth)];
+ final rendered = await _reference(reference, b, depth);
+ _attributes(rendered, b.attributes);
+ return [rendered];
}
if (result?.url case final url?) {
- return [
- _el(
- 'figure',
- children: [
- _el(
- 'img',
- attrs: {
- 'src': url,
- 'alt': '${descriptor.kind.displayName} diagram',
- },
- ),
- ],
- ),
- ];
+ final figure = _el(
+ 'figure',
+ children: [
+ _el(
+ 'img',
+ attrs: {
+ 'src': url,
+ 'alt': '${descriptor.kind.displayName} diagram',
+ },
+ ),
+ ],
+ );
+ _attributes(figure, b.attributes);
+ return [figure];
}
}
final language = b.attributes['language'] ?? '';
@@ -577,7 +603,12 @@ class HtmlDocumentWriter {
_el(
'a',
text: b.attributes['title'] ?? p.basename(src),
- attrs: {'href': url},
+ attrs: {
+ 'href': url,
+ 'download': p.basename(
+ Uri.decodeComponent(Uri.parse(src).path),
+ ),
+ },
),
],
);
@@ -857,22 +888,21 @@ class HtmlDocumentWriter {
case BusyInlineKind.image:
node = await _image(value.destination ?? '', value.text, a, block);
case BusyInlineKind.link:
- final url = await links.resolve(
+ final link = await links.resolve(
value.destination,
page,
a,
line: block.sourceSpan?.startLine ?? 1,
);
- if (url == null) {
+ if (link == null) {
node = _el('span', children: nodes);
} else {
node = _el(
'a',
children: nodes,
attrs: {
- 'href': url,
- if (a['element'] == 'resource')
- 'download': p.basename(value.text),
+ 'href': link.url,
+ if (link.downloadName case final name?) 'download': name,
if (a['summary'] case final summary?) 'title': summary,
},
);
@@ -1039,11 +1069,16 @@ class HtmlDocumentWriter {
final element = _el(tag, children: await nodes(value.nodes, depth + 1));
_attributes(element, attrs);
if (tag == 'a') {
- final url = await links.resolve(attrs['href'], page, {
+ final link = await links.resolve(attrs['href'], page, {
...block.attributes,
...attrs,
}, line: block.sourceSpan?.startLine ?? 1);
- if (url != null) element.attributes['href'] = url;
+ if (link != null) {
+ element.attributes['href'] = link.url;
+ if (link.downloadName case final name?) {
+ element.attributes['download'] = name;
+ }
+ }
}
if (tag == 'details') {
if (attrs.containsKey('open')) element.attributes['open'] = '';
diff --git a/lib/src/export/html_export_links.dart b/lib/src/export/html_export_links.dart
index acee34b1..f8798deb 100644
--- a/lib/src/export/html_export_links.dart
+++ b/lib/src/export/html_export_links.dart
@@ -5,6 +5,14 @@ import 'html_export_assets.dart';
import 'html_export_models.dart';
import 'html_publication_plan.dart';
+/// A validated destination with attachment semantics retained for the writer.
+class HtmlResolvedLink {
+ const HtmlResolvedLink(this.url, {this.downloadName});
+
+ final String url;
+ final String? downloadName;
+}
+
class HtmlExportLinks {
HtmlExportLinks({
required this.plan,
@@ -54,7 +62,28 @@ class HtmlExportLinks {
return uri.toString();
}
- Future resolve(
+ Future resolve(
+ String? value,
+ HtmlPage page,
+ Map attributes, {
+ int line = 1,
+ }) async {
+ try {
+ return await _resolve(value, page, attributes, line: line);
+ } on FormatException {
+ warnings.add(
+ HtmlExportWarning(
+ 'link.unresolved',
+ 'Malformed encoded link destination was omitted.',
+ sourcePath: source(page, attributes),
+ line: line,
+ ),
+ );
+ return null;
+ }
+ }
+
+ Future _resolve(
String? value,
HtmlPage page,
Map attributes, {
@@ -62,7 +91,7 @@ class HtmlExportLinks {
}) async {
if (value == null || value.trim().isEmpty) return null;
final destination = value.trim();
- if (external(destination) case final url?) return url;
+ if (external(destination) case final url?) return HtmlResolvedLink(url);
final origin = source(page, attributes);
void warn(String message) => warnings.add(
HtmlExportWarning(
@@ -77,13 +106,14 @@ class HtmlExportLinks {
return null;
}
if (attributes['element'] == 'resource') {
- return assets.local(
+ final url = await assets.local(
destination,
sourcePath: origin,
searchRoots: roots(page, attributes),
line: line,
download: true,
);
+ return url == null ? null : _attachment(url, destination);
}
final uri = Uri.tryParse(destination);
if (uri == null ||
@@ -130,7 +160,9 @@ class HtmlExportLinks {
warn('Missing anchor "$fragment" in ${p.basename(target.sourcePath)}.');
return null;
}
- return '${target == page ? (fragment.isEmpty ? Uri.encodeComponent(target.filename) : '') : Uri.encodeComponent(target.filename)}${fragment.isEmpty ? '' : '#${Uri.encodeComponent(fragment)}'}';
+ return HtmlResolvedLink(
+ '${target == page ? (fragment.isEmpty ? Uri.encodeComponent(target.filename) : '') : Uri.encodeComponent(target.filename)}${fragment.isEmpty ? '' : '#${Uri.encodeComponent(fragment)}'}',
+ );
}
if (attributes['nullable'] == 'true') return null;
if (const {
@@ -153,6 +185,14 @@ class HtmlExportLinks {
if (asset == null) {
warn('Link target ${p.basename(path)} could not be resolved.');
}
- return asset;
+ return asset == null ? null : _attachment(asset, destination);
}
+
+ HtmlResolvedLink _attachment(String url, String destination) =>
+ HtmlResolvedLink(
+ url,
+ downloadName: p.basename(
+ Uri.decodeComponent(Uri.parse(destination).path),
+ ),
+ );
}
diff --git a/lib/src/export/html_publication_plan.dart b/lib/src/export/html_publication_plan.dart
index d1a757b4..8904cedf 100644
--- a/lib/src/export/html_publication_plan.dart
+++ b/lib/src/export/html_publication_plan.dart
@@ -277,11 +277,38 @@ void prepareHtmlPage(
);
}
}
+ final raw =
+ b.attributes['html-footnotes'] ??
+ (b.kind == BusyBlockKind.htmlBlock ? b.rawSource : null);
+ if (raw != null) {
+ explicit.addAll(
+ html.parseFragment(raw).querySelectorAll('[id]').map((e) => e.id),
+ );
+ }
+ void inlines(Iterable values) {
+ for (final value in values) {
+ if (value.attributes['id'] case final id?) explicit.add(id);
+ if (value.kind == BusyInlineKind.html) {
+ explicit.addAll(
+ html
+ .parseFragment(value.text)
+ .querySelectorAll('[id]')
+ .map((e) => e.id),
+ );
+ }
+ inlines(value.children);
+ }
+ }
+
+ inlines(b.inlines);
collect(b.children, depth + 1);
}
}
collect(page.document.blocks, 0);
+ if (page.topic?.document.rootElement?.attributes['id'] case final id?) {
+ explicit.add(id);
+ }
final used = {};
var occurrence = 0;
BusyBlock block(BusyBlock b) {
@@ -360,6 +387,14 @@ void prepareHtmlPage(
void inlineIds(Iterable values) {
for (final value in values) {
if (value.attributes['id'] case final id?) page.ids.add(id);
+ if (value.kind == BusyInlineKind.html) {
+ page.ids.addAll(
+ html
+ .parseFragment(value.text)
+ .querySelectorAll('[id]')
+ .map((e) => e.id),
+ );
+ }
inlineIds(value.children);
}
}
diff --git a/lib/src/export/markdown_export_mapper.dart b/lib/src/export/markdown_export_mapper.dart
index 184bec3a..070c7262 100644
--- a/lib/src/export/markdown_export_mapper.dart
+++ b/lib/src/export/markdown_export_mapper.dart
@@ -5,6 +5,7 @@ import '../markdown/busymark_document.dart';
import '../writerside/writerside_video.dart';
import 'markdown_export_document.dart';
import 'export_metadata_mapper.dart';
+import 'pdf_footnotes.dart';
class MarkdownExportMapper {
const MarkdownExportMapper();
@@ -15,10 +16,64 @@ class MarkdownExportMapper {
}) {
return MarkdownExportDocument(
metadata: const ExportMetadataMapper().map(document),
- blocks: _mapBlocks(document.blocks, blockOverrides),
+ blocks: _connectFootnotes(
+ _mapBlocks(preparePdfFootnotes(document).blocks, blockOverrides),
+ ),
);
}
+ List _connectFootnotes(
+ List blocks,
+ ) {
+ final definitions = {};
+ void collect(Iterable values) {
+ for (final block in values) {
+ if (block.attributes['footnoteId'] case final String id) {
+ definitions.add(id);
+ }
+ collect(block.children);
+ }
+ }
+
+ collect(blocks);
+ final referenced = {};
+ MarkdownExportInline inline(MarkdownExportInline value) {
+ final destination = value.destination;
+ String? id;
+ if (value.kind == MarkdownExportInlineKind.link &&
+ destination != null &&
+ destination.startsWith('#')) {
+ final raw = destination.substring(1);
+ if (definitions.contains(raw)) {
+ id = raw;
+ } else {
+ try {
+ final decoded = Uri.decodeComponent(raw);
+ if (definitions.contains(decoded)) id = decoded;
+ } on FormatException {
+ /* An unrelated malformed link is not a note. */
+ }
+ }
+ }
+ return value.copyWith(
+ attributes: {
+ ...value.attributes,
+ if (id != null) ...{
+ 'footnoteId': id,
+ 'footnoteFirst': referenced.add(id).toString(),
+ },
+ },
+ children: value.children.map(inline).toList(),
+ );
+ }
+
+ MarkdownExportBlock block(MarkdownExportBlock value) => value.copyWith(
+ inlines: value.inlines.map(inline).toList(),
+ children: value.children.map(block).toList(),
+ );
+ return blocks.map(block).toList();
+ }
+
List _mapBlocks(
List blocks,
Map blockOverrides,
@@ -47,9 +102,11 @@ class MarkdownExportMapper {
);
}
currentVariant = variant;
- final override = blockOverrides[block.id];
+ final override = block.kind == BusyBlockKind.codeBlock
+ ? blockOverrides[block.id]
+ : null;
if (override != null) {
- result.add(override);
+ result.add(_withAnchor(override, block));
index++;
continue;
}
@@ -57,15 +114,21 @@ class MarkdownExportMapper {
final ordered = _isOrderedListItem(block);
final listType =
block.attributes['listType'] ?? (ordered ? 'decimal' : 'bullet');
+ final listId = block.attributes['listId'];
final items = [];
- final start = _listNumber(block.attributes['marker']) ?? 1;
+ final start =
+ int.tryParse(block.attributes['listOrdinal'] ?? '') ??
+ _listNumber(block.attributes['marker']) ??
+ 1;
while (index < blocks.length && _isListItem(blocks[index].kind)) {
final candidate = blocks[index];
final candidateOrdered = _isOrderedListItem(candidate);
final candidateListType =
candidate.attributes['listType'] ??
(candidateOrdered ? 'decimal' : 'bullet');
- if (candidateOrdered != ordered || candidateListType != listType) {
+ if (candidateOrdered != ordered ||
+ candidateListType != listType ||
+ candidate.attributes['listId'] != listId) {
break;
}
items.add(_mapListItem(candidate, blockOverrides));
@@ -86,15 +149,7 @@ class MarkdownExportMapper {
}
final mapped = _mapBlock(block, blockOverrides);
if (mapped != null) {
- result.add(
- mapped.copyWith(
- attributes: {
- ...mapped.attributes,
- if (block.attributes['pdf-anchor'] case final anchor?)
- 'anchor': anchor,
- },
- ),
- );
+ result.add(_withAnchor(mapped, block));
if (block.kind == BusyBlockKind.heading ||
block.kind == BusyBlockKind.paragraph) {
result.addAll(_mapBlocks(block.children, blockOverrides));
@@ -105,6 +160,17 @@ class MarkdownExportMapper {
return List.unmodifiable(result);
}
+ MarkdownExportBlock _withAnchor(
+ MarkdownExportBlock mapped,
+ BusyBlock source,
+ ) => mapped.copyWith(
+ attributes: {
+ ...mapped.attributes,
+ if (source.attributes['pdf-anchor'] case final anchor?) 'anchor': anchor,
+ if (source.attributes['pdf-footnote-id'] case final id?) 'footnoteId': id,
+ },
+ );
+
MarkdownExportBlock _mapListItem(
BusyBlock block,
Map blockOverrides,
@@ -115,6 +181,7 @@ class MarkdownExportMapper {
children: _mapBlocks(block.children, blockOverrides),
attributes: {
if (block.attributes['task'] case final task?) 'task': task == 'true',
+ if (block.attributes['pdf-anchor'] case final anchor?) 'anchor': anchor,
},
);
}
diff --git a/lib/src/export/markdown_pdf_export_service.dart b/lib/src/export/markdown_pdf_export_service.dart
index 75a48431..5a96ae23 100644
--- a/lib/src/export/markdown_pdf_export_service.dart
+++ b/lib/src/export/markdown_pdf_export_service.dart
@@ -84,6 +84,15 @@ class MarkdownPdfExportService {
validateLocalReferences: false,
)).busyDocument;
token.throwIfCancelled();
+ final titlePage = request.options.includeTitlePage
+ ? request.titlePage ?? PdfTitlePageData.fromDocument(busyDocument)
+ : null;
+ if (titlePage != null && titlePage.validate().isNotEmpty) {
+ throw const MarkdownPdfExportException(
+ MarkdownPdfFailureCode.compilerFailed,
+ detail: 'The PDF title page requires a non-empty title.',
+ );
+ }
final visualizationPreparation = visualizationRenderer == null
? const MarkdownVisualizationExportPreparation(
blockOverrides: {},
@@ -125,6 +134,7 @@ class MarkdownPdfExportService {
document: document,
options: request.options,
assets: stagedAssets.assets,
+ titlePage: titlePage,
);
await Future.wait([
File(
@@ -192,7 +202,9 @@ class MarkdownPdfExportService {
}
return MarkdownPdfExportResult(
destinationPath: p.normalize(p.absolute(request.destinationPath)),
- pageCount: _pageCount(pdfBytes),
+ // Without a PDF page-tree parser, the count is unknown. Raw byte
+ // matches also count /Pages dictionaries and arbitrary stream contents.
+ pageCount: null,
warnings: [
...visualizationPreparation.warnings,
...mathPreparation.warnings,
@@ -239,37 +251,6 @@ class MarkdownPdfExportService {
return latin1.decode(bytes.sublist(tailStart)).contains('%%EOF');
}
- int? _pageCount(List bytes) {
- const needle = [
- 0x2f,
- 0x54,
- 0x79,
- 0x70,
- 0x65,
- 0x20,
- 0x2f,
- 0x50,
- 0x61,
- 0x67,
- 0x65,
- ];
- var count = 0;
- for (var index = 0; index <= bytes.length - needle.length; index++) {
- var matches = true;
- for (var offset = 0; offset < needle.length; offset++) {
- if (bytes[index + offset] != needle[offset]) {
- matches = false;
- break;
- }
- }
- if (matches) {
- count++;
- index += needle.length - 1;
- }
- }
- return count == 0 ? null : count;
- }
-
Future _deleteExportRootBestEffort(Directory directory) async {
try {
if (await directory.exists()) {
diff --git a/lib/src/export/markdown_pdf_export_ui.dart b/lib/src/export/markdown_pdf_export_ui.dart
index 9237eda7..75d4d3c8 100644
--- a/lib/src/export/markdown_pdf_export_ui.dart
+++ b/lib/src/export/markdown_pdf_export_ui.dart
@@ -15,6 +15,8 @@ import '../platform/linux_header_bar_service.dart';
import '../math/math_providers.dart';
import '../workspace/workspace_model.dart';
import '../workspace/workspace_controller.dart';
+import '../markdown/busymark_document.dart';
+import '../markdown/markdown_parser.dart';
import '../visualization/visualization_providers.dart';
import 'markdown_visualization_export.dart';
import 'export_options_editor.dart';
@@ -62,8 +64,12 @@ Future exportWorkspaceToPdf(
BuildContext context,
WidgetRef ref, {
ExportOptionsSelection? configuredSelection,
+ WorkspaceState? exportSnapshot,
+ BusyDocument? documentSnapshot,
}) {
- final workspace = ref.read(workspaceControllerProvider).workspace;
+ final WorkspaceState snapshot =
+ exportSnapshot ?? ref.read(workspaceControllerProvider);
+ final workspace = snapshot.workspace;
if (workspace?.kind == WorkspaceKind.writersideModule) {
return exportWritersideModuleToPdf(
context,
@@ -75,6 +81,8 @@ Future exportWorkspaceToPdf(
context,
ref,
configuredSelection: configuredSelection,
+ exportSnapshot: exportSnapshot,
+ documentSnapshot: documentSnapshot,
);
}
@@ -82,16 +90,27 @@ Future exportActiveMarkdownToPdf(
BuildContext context,
WidgetRef ref, {
ExportOptionsSelection? configuredSelection,
+ WorkspaceState? exportSnapshot,
+ BusyDocument? documentSnapshot,
}) async {
- final snapshot = ref.read(workspaceControllerProvider);
+ final WorkspaceState snapshot =
+ exportSnapshot ?? ref.read(workspaceControllerProvider);
if (!canExportActiveMarkdown(snapshot)) {
return;
}
final workspace = snapshot.workspace!;
final headerBar = ref.read(linuxHeaderBarServiceProvider);
+ final document =
+ documentSnapshot ?? await prepareMarkdownPdfSnapshot(snapshot);
+ if (!context.mounted) return;
final selected =
configuredSelection ??
- await showExportOptions(context, ref, canExportHtml: false);
+ await showExportOptions(
+ context,
+ ref,
+ canExportHtml: false,
+ pdfTitlePageDefaults: PdfTitlePageData.fromDocument(document),
+ );
if (selected == null || selected.pdf == null || !context.mounted) return;
final options = selected.pdf!;
@@ -147,6 +166,8 @@ Future exportActiveMarkdownToPdf(
destinationPath: destinationPath,
options: options,
overwrite: overwrite,
+ document: document,
+ titlePage: selected.titlePage,
);
final outcome = await showBusyMarkModalDialog<_PdfExportOutcome>(
context,
@@ -187,6 +208,18 @@ Future exportActiveMarkdownToPdf(
);
}
+/// One immutable snapshot supplies both the cover defaults and exported body.
+Future prepareMarkdownPdfSnapshot(WorkspaceState snapshot) async {
+ final workspace = snapshot.workspace!;
+ return (await const MarkdownParser().parseAsync(
+ source: snapshot.activeText,
+ filePath: workspace.activeFilePath ?? workspace.markdown?.filePath ?? '',
+ workspaceRoot: workspace.rootPath.isEmpty ? null : workspace.rootPath,
+ mode: workspace.markdown!.mode,
+ validateLocalReferences: false,
+ )).busyDocument;
+}
+
String? _initialDirectory(Workspace workspace, String? activePath) {
if (activePath != null && activePath.isNotEmpty) {
return p.dirname(activePath);
diff --git a/lib/src/export/markdown_pdf_models.dart b/lib/src/export/markdown_pdf_models.dart
index 4985e5ac..65404da7 100644
--- a/lib/src/export/markdown_pdf_models.dart
+++ b/lib/src/export/markdown_pdf_models.dart
@@ -2,9 +2,11 @@ import 'package:flutter/foundation.dart';
import '../markdown/markdown_model.dart';
import 'export_options.dart';
+import 'pdf_title_page.dart';
import '../markdown/busymark_document.dart';
export 'export_options.dart';
+export 'pdf_title_page.dart';
enum MarkdownPdfWarningCode {
remoteImageSkipped,
@@ -39,6 +41,7 @@ class MarkdownPdfExportRequest {
required this.overwrite,
this.mode = MarkdownMode.commonMark,
this.document,
+ this.titlePage,
});
final String source;
@@ -52,6 +55,9 @@ class MarkdownPdfExportRequest {
/// A pre-parsed semantic document. When present, PDF export consumes this
/// tree directly and [source] is not reparsed.
final BusyDocument? document;
+
+ /// Resolved export-specific text, or null to use this request's snapshot.
+ final PdfTitlePageData? titlePage;
}
@immutable
diff --git a/lib/src/export/markdown_visualization_export.dart b/lib/src/export/markdown_visualization_export.dart
index deccfecf..43d6d69f 100644
--- a/lib/src/export/markdown_visualization_export.dart
+++ b/lib/src/export/markdown_visualization_export.dart
@@ -62,7 +62,7 @@ class MarkdownVisualizationExportRenderer {
}
final selected = candidates.take(maximumBlocks).toList(growable: false);
final keys = [
- for (final block in selected) 'export:$documentPath:${block.id}',
+ for (final block in selected) 'export:${exportRoot.path}:${block.id}',
];
cancellationToken.attach(() {
for (final key in keys) {
diff --git a/lib/src/export/pdf_footnotes.dart b/lib/src/export/pdf_footnotes.dart
new file mode 100644
index 00000000..98da0c5c
--- /dev/null
+++ b/lib/src/export/pdf_footnotes.dart
@@ -0,0 +1,152 @@
+import 'package:html/dom.dart' as dom;
+import 'package:html/parser.dart' as html;
+
+import '../markdown/busymark_document.dart';
+import '../markdown/math_syntax.dart';
+import '../markdown/raw_html_adapter.dart';
+import '../writerside/writerside_document.dart';
+
+/// Recover the parser's structured definitions before PDF assets and links are
+/// resolved. HTML keeps using the original structure on its own export path.
+BusyDocument preparePdfFootnotes(BusyDocument document) {
+ var occurrence = 0;
+ String nextId() => 'pdf-footnote-${occurrence++}';
+ BusyBlock convert(BusyBlock block) {
+ final source = block.attributes['html-footnotes'];
+ if (source == null) {
+ return block.copyWith(children: block.children.map(convert).toList());
+ }
+ final origin = {
+ for (final name in [
+ writersideSourceModuleRootAttribute,
+ writersideSourceTopicPathAttribute,
+ writersideSourceOccurrenceAttribute,
+ ])
+ if (block.attributes[name] case final value?) name: value,
+ };
+ BusyInline withInlineOrigin(BusyInline inline) => inline.copyWith(
+ attributes: {...origin, ...inline.attributes},
+ children: inline.children.map(withInlineOrigin).toList(),
+ );
+ BusyBlock withOrigin(BusyBlock block) => block.copyWith(
+ attributes: {...origin, ...block.attributes},
+ inlines: block.inlines.map(withInlineOrigin).toList(),
+ children: block.children.map(withOrigin).toList(),
+ );
+ final definitions = [];
+ for (final definition
+ in html.parseFragment(source).querySelectorAll('ol > li[id]')) {
+ for (final backlink in definition.querySelectorAll('.footnote-backref')) {
+ backlink.remove();
+ }
+ final parsed = _definitionBlocks(definition, nextId);
+ definitions.add(
+ BusyBlock(
+ id: nextId(),
+ kind: BusyBlockKind.unknown,
+ attributes: {...origin, 'pdf-footnote-id': definition.id},
+ children: parsed != null
+ ? parsed.map(withOrigin).toList()
+ : [
+ BusyBlock(
+ id: nextId(),
+ kind: BusyBlockKind.paragraph,
+ inlines: [
+ BusyInline(
+ kind: BusyInlineKind.text,
+ text: definition.text,
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+ return block.copyWith(
+ inlines: const [],
+ children: definitions,
+ attributes: {...block.attributes}..remove('html-footnotes'),
+ );
+ }
+
+ return document.copyWith(blocks: document.blocks.map(convert).toList());
+}
+
+List? _definitionBlocks(
+ dom.Element definition,
+ String Function() nextId,
+) {
+ final body = definition.clone(true);
+ // These nodes came from the Markdown parser's math syntax. Extract them
+ // before applying the authored-HTML allowlist, then restore semantic math
+ // through placeholders which cannot collide with any authored content.
+ var prefix = 'busymark-footnote-math';
+ final source = definition.outerHtml;
+ while (source.contains(prefix)) {
+ prefix += '-';
+ }
+ final math =
+ {};
+ for (final node in body.querySelectorAll(
+ '$busyMarkMathInlineTag, $busyMarkMathBlockTag',
+ )) {
+ // The HTML parser folds attribute names to lower case. Only recover the
+ // complete signature emitted by Markdown math syntax; authored custom tags
+ // still pass through the ordinary raw-HTML policy.
+ final expression =
+ node.attributes[busyMarkMathExpressionAttribute.toLowerCase()];
+ final sourceForm =
+ node.attributes[busyMarkMathSourceFormAttribute.toLowerCase()];
+ final display = node.localName == busyMarkMathBlockTag;
+ if (expression == null ||
+ sourceForm == null ||
+ !BusyMathSourceForm.values.any((form) => form.name == sourceForm) ||
+ node.attributes[busyMarkMathDisplayAttribute.toLowerCase()] !=
+ '$display') {
+ continue;
+ }
+ final marker = '$prefix-${math.length}';
+ math[marker] = (
+ expression: expression,
+ sourceForm: sourceForm,
+ display: display,
+ );
+ node.replaceWith(dom.Element.tag(display ? 'pre' : 'code')..text = marker);
+ }
+ final parsed = const RawHtmlAdapter().parseRawHtmlBlock(
+ '${body.innerHtml}
',
+ nextId,
+ );
+ if (parsed?.safe != true) return null;
+
+ BusyInline inline(BusyInline value) {
+ final item = value.kind == BusyInlineKind.code ? math[value.text] : null;
+ if (item != null && !item.display) {
+ return BusyInline(
+ kind: BusyInlineKind.math,
+ text: item.expression,
+ attributes: {busyMarkMathSourceFormAttribute: item.sourceForm},
+ );
+ }
+ return value.copyWith(children: value.children.map(inline).toList());
+ }
+
+ BusyBlock block(BusyBlock value) {
+ final item = value.kind == BusyBlockKind.codeBlock
+ ? math[value.plainText]
+ : null;
+ if (item != null && item.display) {
+ return value.copyWith(
+ kind: BusyBlockKind.math,
+ inlines: [BusyInline(kind: BusyInlineKind.math, text: item.expression)],
+ attributes: {busyMarkMathSourceFormAttribute: item.sourceForm},
+ );
+ }
+ return value.copyWith(
+ inlines: value.inlines.map(inline).toList(),
+ children: value.children.map(block).toList(),
+ );
+ }
+
+ return parsed!.blocks.map(block).toList();
+}
diff --git a/lib/src/export/pdf_title_page.dart b/lib/src/export/pdf_title_page.dart
new file mode 100644
index 00000000..a889a66d
--- /dev/null
+++ b/lib/src/export/pdf_title_page.dart
@@ -0,0 +1,85 @@
+import 'package:flutter/foundation.dart';
+
+import '../markdown/busymark_document.dart';
+import '../markdown/markdown_model.dart';
+import '../writerside/writerside_model.dart';
+import 'export_metadata_mapper.dart';
+import 'export_options.dart';
+
+/// Resolved text for one PDF export, never a global preference. Empty optional
+/// values deliberately suppress metadata defaults. All values are plain text.
+@immutable
+class PdfTitlePageData {
+ const PdfTitlePageData({
+ required this.title,
+ this.subtitle = '',
+ this.author = '',
+ this.organization = '',
+ this.version = '',
+ this.date = '',
+ });
+
+ factory PdfTitlePageData.fromDocument(
+ BusyDocument document, {
+ String? titleOverride,
+ }) {
+ final metadata = const ExportMetadataMapper().map(
+ document,
+ titleOverride: titleOverride,
+ );
+ final source = {
+ for (final entry in document.frontMatter.entries)
+ entry.key.toLowerCase().trim(): entry.value,
+ };
+ return PdfTitlePageData(
+ title: metadata.title,
+ author: metadata.author,
+ subtitle: source['subtitle'] ?? '',
+ organization: source['organization'] ?? '',
+ version: source['version'] ?? '',
+ date: source['date'] ?? '',
+ );
+ }
+
+ factory PdfTitlePageData.forInstance(WritersideInstance instance) =>
+ PdfTitlePageData.fromDocument(
+ BusyDocument(
+ filePath: '',
+ mode: MarkdownMode.writersideMarkdown,
+ title: instance.name,
+ blocks: const [],
+ ),
+ titleOverride: instance.name,
+ ).copyWith(version: instance.effectiveVersion ?? '');
+
+ final String title, subtitle, author, organization, version, date;
+
+ PdfTitlePageData copyWith({
+ String? title,
+ String? subtitle,
+ String? author,
+ String? organization,
+ String? version,
+ String? date,
+ }) => PdfTitlePageData(
+ title: title ?? this.title,
+ subtitle: subtitle ?? this.subtitle,
+ author: author ?? this.author,
+ organization: organization ?? this.organization,
+ version: version ?? this.version,
+ date: date ?? this.date,
+ );
+
+ List validate() => [
+ if (title.trim().isEmpty) const ExportOptionIssue('titlePage.title'),
+ ];
+
+ Map toJson() => {
+ 'title': title,
+ if (subtitle.trim().isNotEmpty) 'subtitle': subtitle,
+ if (author.trim().isNotEmpty) 'author': author,
+ if (organization.trim().isNotEmpty) 'organization': organization,
+ if (version.trim().isNotEmpty) 'version': version,
+ if (date.trim().isNotEmpty) 'date': date,
+ };
+}
diff --git a/lib/src/export/typst_payload_builder.dart b/lib/src/export/typst_payload_builder.dart
index a7216453..59a3aa66 100644
--- a/lib/src/export/typst_payload_builder.dart
+++ b/lib/src/export/typst_payload_builder.dart
@@ -8,12 +8,21 @@ class TypstPayloadBuilder {
required MarkdownExportDocument document,
required PdfExportOptions options,
required Map assets,
+ PdfTitlePageData? titlePage,
}) {
options.validateOrThrow();
+ if (options.includeTitlePage &&
+ (titlePage == null || titlePage.validate().isNotEmpty)) {
+ throw const ExportOptionsException([
+ ExportOptionIssue('titlePage.title'),
+ ]);
+ }
return {
'schemaVersion': 2,
'metadata': document.metadata.toJson(),
+ if (options.includeTitlePage) 'titlePage': titlePage!.toJson(),
'options': {
+ 'includeTitlePage': options.includeTitlePage,
'page': options.geometry.toJson(),
'typography': {
'bodyFont': options.bodyFont,
diff --git a/lib/src/export/workspace_export_ui.dart b/lib/src/export/workspace_export_ui.dart
index de76071a..e777d32c 100644
--- a/lib/src/export/workspace_export_ui.dart
+++ b/lib/src/export/workspace_export_ui.dart
@@ -6,6 +6,7 @@ import '../workspace/workspace_model.dart';
import 'export_options_editor.dart';
import 'html_export_ui.dart';
import 'markdown_pdf_export_ui.dart';
+import 'pdf_title_page.dart';
export 'html_export_ui.dart' show canExportWorkspaceHtml;
export 'markdown_pdf_export_ui.dart' show canExportWorkspacePdf;
@@ -21,6 +22,10 @@ Future exportWorkspace(BuildContext context, WidgetRef ref) async {
final workspace = state.workspace!;
final writerside = workspace.kind == WorkspaceKind.writersideModule;
+ final pdfDocument = !writerside && canExportPdf
+ ? await prepareMarkdownPdfSnapshot(state)
+ : null;
+ if (!context.mounted) return;
final selection = await showExportOptions(
context,
ref,
@@ -33,11 +38,20 @@ Future exportWorkspace(BuildContext context, WidgetRef ref) async {
.toList(growable: false)
: const [],
workspaceRoot: writerside ? workspace.rootPath : null,
+ pdfTitlePageDefaults: pdfDocument == null
+ ? null
+ : PdfTitlePageData.fromDocument(pdfDocument),
);
if (selection == null || !context.mounted) return;
if (selection.pdf != null) {
- await exportWorkspaceToPdf(context, ref, configuredSelection: selection);
+ await exportWorkspaceToPdf(
+ context,
+ ref,
+ configuredSelection: selection,
+ exportSnapshot: state,
+ documentSnapshot: pdfDocument,
+ );
} else if (selection.html != null) {
await exportWorkspaceToHtml(context, ref, configuredSelection: selection);
}
diff --git a/lib/src/export/writerside_pdf_export_service.dart b/lib/src/export/writerside_pdf_export_service.dart
index c8692d9a..932140f1 100644
--- a/lib/src/export/writerside_pdf_export_service.dart
+++ b/lib/src/export/writerside_pdf_export_service.dart
@@ -14,6 +14,7 @@ import '../writerside/writerside_document.dart';
import '../writerside/writerside_model.dart';
import '../writerside/writerside_module_service.dart';
import '../writerside/writerside_project.dart';
+import 'pdf_footnotes.dart';
import 'markdown_pdf_export_service.dart';
import 'markdown_pdf_models.dart';
import 'writerside_pdf_models.dart';
@@ -97,8 +98,8 @@ class WritersidePdfExportService {
throw WritersidePdfExportException(
WritersidePdfFailureCode.invalidRequest,
detail:
- 'Writerside module validation failed: '
- '${moduleErrors.map((diagnostic) => diagnostic.code).toSet().join(', ')}',
+ 'Writerside module validation failed:\n'
+ '${_diagnosticDetails(moduleErrors, moduleRoot)}',
);
}
final composition = await _composeInstanceDocument(
@@ -114,8 +115,8 @@ class WritersidePdfExportService {
throw WritersidePdfExportException(
WritersidePdfFailureCode.invalidRequest,
detail:
- 'Writerside resolution failed: '
- '${resolutionErrors.map((diagnostic) => diagnostic.code).join(', ')}',
+ 'Writerside resolution failed:\n'
+ '${_diagnosticDetails(resolutionErrors, moduleRoot)}',
);
}
final document = composition.document;
@@ -135,6 +136,9 @@ class WritersidePdfExportService {
workspaceRoot: moduleRoot,
destinationPath: request.destinationPath,
options: request.options,
+ titlePage: request.options.includeTitlePage
+ ? request.titlePage ?? PdfTitlePageData.forInstance(instance)
+ : null,
overwrite: request.overwrite,
mode: MarkdownMode.writersideMarkdown,
document: document,
@@ -211,54 +215,73 @@ class WritersidePdfExportService {
Map modulesByOrigin,
WritersidePdfCancellationToken token,
) async {
- final selected = <({WritersideTopic topic, bool hidden})>[];
+ final selected =
+ <({WritersideModule module, WritersideTopic topic, bool hidden})>[];
final selectedIndices = {};
+ final startTopic = instance.startPage == null
+ ? null
+ : module.topicByReference(instance.startPage!);
+ bool? startHidden;
- void addReference(String? reference, {required bool hidden}) {
+ void addReference(
+ String? reference,
+ WritersideModule owner, {
+ required bool hidden,
+ }) {
if (reference == null) {
return;
}
- final topic = module.topicByReference(reference);
- if (topic == null) return;
+ final topic = owner.topicByReference(reference);
+ if (topic == null) {
+ throw WritersidePdfExportException(
+ WritersidePdfFailureCode.invalidRequest,
+ detail: 'Missing TOC topic: $reference (${owner.rootPath}).',
+ );
+ }
final existingIndex = selectedIndices[topic.filePath];
if (existingIndex == null) {
selectedIndices[topic.filePath] = selected.length;
- selected.add((topic: topic, hidden: hidden));
+ selected.add((module: owner, topic: topic, hidden: hidden));
} else if (selected[existingIndex].hidden && !hidden) {
- selected[existingIndex] = (topic: topic, hidden: false);
+ selected[existingIndex] = (module: owner, topic: topic, hidden: false);
}
}
- void addNode(TocNode node) {
- if (!node.workInProgress) {
- addReference(node.topicReference, hidden: node.hidden);
+ void addNode(TocNode node, WritersideModule inherited) {
+ final owner = node.origin == null
+ ? inherited
+ : modulesByOrigin[node.origin];
+ if (owner == null) {
+ throw WritersidePdfExportException(
+ WritersidePdfFailureCode.invalidRequest,
+ detail: 'Unknown TOC module origin: ${node.origin}.',
+ );
+ }
+ final publishesTopic =
+ node.referenceInstanceId == null ||
+ node.referenceInstanceId == instance.id;
+ if (publishesTopic &&
+ startTopic != null &&
+ node.topicReference != null &&
+ owner.topicByReference(node.topicReference!)?.filePath ==
+ startTopic.filePath) {
+ startHidden = (startHidden ?? true) && node.hidden;
+ }
+ if (publishesTopic && !node.workInProgress) {
+ addReference(node.topicReference, owner, hidden: node.hidden);
}
for (final child in node.children) {
- addNode(child);
+ addNode(child, owner);
}
}
for (final root in instance.navigationTocRoots) {
- addNode(root);
+ addNode(root, module);
}
- final startTopic = instance.startPage == null
- ? null
- : module.topicByReference(instance.startPage!);
if (startTopic != null) {
- final startNodes = instance.navigationTocRoots
- .expand((root) => root.flatten())
- .where(
- (node) =>
- node.topicReference != null &&
- module.topicByReference(node.topicReference!)?.filePath ==
- startTopic.filePath,
- )
- .toList(growable: false);
- final startHidden =
- startNodes.isNotEmpty && startNodes.every((node) => node.hidden);
final existingIndex = selectedIndices[startTopic.filePath];
final start = existingIndex == null
- ? (topic: startTopic, hidden: startHidden)
+ ? (module: module, topic: startTopic, hidden: startHidden ?? false)
: selected.removeAt(existingIndex);
selected.insert(0, start);
selectedIndices
@@ -269,11 +292,6 @@ class WritersidePdfExportService {
),
);
}
- if (selected.isEmpty) {
- for (final reference in instance.topicFileSet) {
- addReference(reference, hidden: false);
- }
- }
if (selected.length > maximumTopics) {
throw const WritersidePdfExportException(
WritersidePdfFailureCode.invalidRequest,
@@ -320,7 +338,7 @@ class WritersidePdfExportService {
final resolved = documentResolver.resolve(
parsedTopic.document,
WritersideResolveContext(
- module: module,
+ module: selection.module,
topic: parsedTopic,
instance: instance,
modulesByOrigin: modulesByOrigin,
@@ -347,19 +365,27 @@ class WritersidePdfExportService {
);
}
final assets = await _resolveBusyAssets(
- module,
+ selection.module,
parsedTopic,
- rendered,
+ preparePdfFootnotes(rendered),
modulesByOrigin,
);
output.addAll(assets.blocks);
}
+ // Parser IDs are local to a source document, and repeated includes reuse
+ // them too. Rendering jobs and mapper overrides must identify occurrences
+ // throughout the complete publication, including nested/generated blocks.
+ var occurrence = 0;
+ BusyBlock identify(BusyBlock block) => block.copyWith(
+ id: 'writerside-export-${occurrence++}',
+ children: block.children.map(identify).toList(growable: false),
+ );
return _ComposedWritersideDocument(
document: BusyDocument(
filePath: p.join(module.rootPath, '.busymark-writerside-export'),
mode: MarkdownMode.writersideMarkdown,
title: instance.name,
- blocks: List.unmodifiable(output),
+ blocks: List.unmodifiable(output.map(identify)),
),
diagnostics: sortDiagnostics(resolutionDiagnostics),
);
@@ -392,6 +418,25 @@ class WritersidePdfExportService {
];
}
+ String _diagnosticDetails(
+ Iterable diagnostics,
+ String moduleRoot,
+ ) {
+ return diagnostics
+ .map((diagnostic) {
+ final path = p.relative(diagnostic.filePath, from: moduleRoot);
+ final location =
+ '$path${diagnostic.line == null ? '' : ':${diagnostic.line}'}';
+ final message = diagnostic.code == 'writerside.tree.missing-topic'
+ ? 'Missing topic "${diagnostic.args['topic']}"'
+ : diagnostic.args.entries
+ .map((entry) => '${entry.key}=${entry.value}')
+ .join(', ');
+ return '$location: ${message.isEmpty ? '' : '$message '}[${diagnostic.code}]';
+ })
+ .join('\n');
+ }
+
bool _isDeferredCrossModuleDiagnostic(
WritersideModule module,
Diagnostic diagnostic,
@@ -454,16 +499,32 @@ class WritersidePdfExportService {
return (module: sourceModule, topic: sourceTopic);
}
+ String footnoteAnchor(Map attributes, String id) {
+ final source = sourceContext(attributes);
+ final identity = jsonEncode([
+ topic.filePath,
+ source.topic.filePath,
+ attributes[writersideSourceOccurrenceAttribute] ?? '0',
+ id,
+ ]);
+ return 'ws-fn-${sha256.convert(utf8.encode(identity)).toString().substring(0, 24)}';
+ }
+
Future resolveInline(BusyInline inline) async {
final source = sourceContext(inline.attributes);
var destination = inline.destination;
if (inline.kind == BusyInlineKind.image && destination != null) {
- destination =
- await _resolvedAssetUri(source.module, source.topic, destination) ??
- destination;
+ destination = await _resolvedAssetUri(
+ source.module,
+ source.topic,
+ destination,
+ );
}
final attributes = {...inline.attributes};
- if (inline.kind == BusyInlineKind.link &&
+ final footnoteTarget = attributes[writersideFootnoteTargetAttribute];
+ if (inline.kind == BusyInlineKind.link && footnoteTarget != null) {
+ destination = '#${footnoteAnchor(attributes, footnoteTarget)}';
+ } else if (inline.kind == BusyInlineKind.link &&
destination != null &&
!(Uri.tryParse(destination)?.hasScheme ?? false)) {
final hash = destination.indexOf('#');
@@ -481,9 +542,11 @@ class WritersidePdfExportService {
if (value == null || value.trim().isEmpty) {
continue;
}
- attributes[name] =
- await _resolvedAssetUri(source.module, source.topic, value) ??
- value;
+ attributes[name] = await _resolvedAssetUri(
+ source.module,
+ source.topic,
+ value,
+ );
}
var text = inline.text;
var children = await Future.wait(inline.children.map(resolveInline));
@@ -511,6 +574,9 @@ class WritersidePdfExportService {
Future resolveBlock(BusyBlock block) async {
final attributes = {...block.attributes};
final source = sourceContext(attributes);
+ if (attributes['pdf-footnote-id'] case final footnoteId?) {
+ attributes['pdf-footnote-id'] = footnoteAnchor(attributes, footnoteId);
+ }
final id = attributes['id'];
final candidate = block.id == 'writerside-document-title'
? anchor(topic.filePath, '')
@@ -527,9 +593,11 @@ class WritersidePdfExportService {
if (value == null || value.trim().isEmpty) {
continue;
}
- attributes[name] =
- await _resolvedAssetUri(source.module, source.topic, value) ??
- value;
+ attributes[name] = await _resolvedAssetUri(
+ source.module,
+ source.topic,
+ value,
+ );
}
return block.copyWith(
inlines: await Future.wait(block.inlines.map(resolveInline)),
@@ -543,33 +611,50 @@ class WritersidePdfExportService {
);
}
- Future _resolvedAssetUri(
+ Future _resolvedAssetUri(
WritersideModule module,
WritersideTopic topic,
String value,
) async {
final destination = value.trim();
final uri = parseSchemedUri(destination);
- if (uri != null) {
- return isRemoteResourceUriScheme(uri.scheme) ? null : destination;
+ // Remote/unsupported schemes are handled without local reads by the asset
+ // stager. File URIs must pass the same canonical containment check as paths.
+ if (uri != null && uri.scheme.toLowerCase() != 'file') return destination;
+ final unavailable = Uri(
+ scheme: 'busymark-unavailable',
+ path: destination,
+ ).toString();
+ final String decoded;
+ try {
+ decoded = uri == null
+ ? Uri.decodeComponent(destination)
+ : uri.toFilePath();
+ } on FormatException {
+ return unavailable;
+ } on UnsupportedError {
+ return unavailable;
}
- final relative = destination.startsWith('/')
- ? destination.substring(1)
- : destination;
- final candidates = [
- p.join(p.dirname(topic.filePath), relative),
- p.join(module.rootPath, relative),
- p.join(module.rootPath, module.effectiveImagesDir, relative),
- if (module.config.resourcesDir case final resourcesDir?)
- p.join(module.rootPath, resourcesDir, relative),
- ];
+ final relative = decoded.startsWith('/') ? decoded.substring(1) : decoded;
+ final candidates = uri != null
+ ? [decoded]
+ : [
+ if (p.isAbsolute(decoded)) decoded,
+ p.join(p.dirname(topic.filePath), relative),
+ p.join(module.rootPath, relative),
+ p.join(module.rootPath, module.effectiveImagesDir, relative),
+ if (module.config.resourcesDir case final resourcesDir?)
+ p.join(module.rootPath, resourcesDir, relative),
+ ];
+ final root = p.normalize(
+ await Directory(module.rootPath).resolveSymbolicLinks(),
+ );
for (final candidate in candidates) {
try {
final canonical = p.normalize(
await File(candidate).resolveSymbolicLinks(),
);
- if ((p.equals(module.rootPath, canonical) ||
- p.isWithin(module.rootPath, canonical)) &&
+ if ((p.equals(root, canonical) || p.isWithin(root, canonical)) &&
await FileSystemEntity.type(canonical, followLinks: false) ==
FileSystemEntityType.file) {
return Uri.file(canonical).toString();
@@ -578,7 +663,9 @@ class WritersidePdfExportService {
// Try the next Writerside-compatible asset root.
}
}
- return null;
+ // Never return the authored path after rejection: Markdown deliberately
+ // permits absolute images, so falling back to it would bypass this boundary.
+ return unavailable;
}
WritersidePdfFailureCode _failureCode(MarkdownPdfFailureCode code) {
diff --git a/lib/src/export/writerside_pdf_export_ui.dart b/lib/src/export/writerside_pdf_export_ui.dart
index 8c74a947..42a6343a 100644
--- a/lib/src/export/writerside_pdf_export_ui.dart
+++ b/lib/src/export/writerside_pdf_export_ui.dart
@@ -125,6 +125,7 @@ Future exportWritersideModuleToPdf(
destinationPath: destination,
overwrite: overwrite,
options: selected.pdf!,
+ titlePage: selected.titlePage,
);
final outcome = await showBusyMarkModalDialog<_WritersidePdfOutcome>(
context,
diff --git a/lib/src/export/writerside_pdf_models.dart b/lib/src/export/writerside_pdf_models.dart
index b66b7ac3..ec27b92f 100644
--- a/lib/src/export/writerside_pdf_models.dart
+++ b/lib/src/export/writerside_pdf_models.dart
@@ -11,6 +11,7 @@ class WritersidePdfExportRequest {
required this.overwrite,
this.projectRoot,
this.options = const PdfExportOptions(),
+ this.titlePage,
});
final String moduleRoot;
@@ -19,6 +20,7 @@ class WritersidePdfExportRequest {
final String destinationPath;
final bool overwrite;
final PdfExportOptions options;
+ final PdfTitlePageData? titlePage;
}
@immutable
diff --git a/lib/src/markdown/authored_html_diagnostics.dart b/lib/src/markdown/authored_html_diagnostics.dart
new file mode 100644
index 00000000..4f7c11aa
--- /dev/null
+++ b/lib/src/markdown/authored_html_diagnostics.dart
@@ -0,0 +1,618 @@
+import 'package:html/dom.dart' as html;
+import 'package:html/parser.dart' as html_parser;
+// HtmlParser exposes its tokenizer, but does not re-export the token types.
+// Inspect start tags before HTML tree construction can discard their attributes.
+// ignore: implementation_imports
+import 'package:html/src/token.dart' show StartTagToken;
+import 'package:markdown/markdown.dart' as md;
+
+import '../core/diagnostic.dart';
+import '../core/source_span.dart';
+import 'markdown_front_matter.dart';
+import 'markdown_model.dart';
+import 'math_syntax.dart';
+import 'raw_html_policy.dart';
+
+/// Parses authored HTML with locations captured before inline escaping and
+/// normalization. Code nodes never enter the HTML diagnostic visitor.
+List authoredHtmlDiagnostics({
+ required String filePath,
+ required String source,
+ required MarkdownMode mode,
+}) => _LocatedDocument(filePath, source, mode).diagnose();
+
+class _LocatedDocument extends md.Document {
+ _LocatedDocument(this.filePath, this.source, this.mode)
+ : super(
+ // Keep the standard syntax classes (and their type checks), adding
+ // location tracking around their existing parse implementations.
+ blockSyntaxes: const [
+ _MathBlock(),
+ _FencedCode(),
+ _Header(),
+ _SetextHeader(),
+ _Table(),
+ _UnorderedList(),
+ _OrderedList(),
+ _Footnote(),
+ _Alert(),
+ _Empty(),
+ _HtmlBlock(),
+ _Code(),
+ _Blockquote(),
+ _Rule(),
+ _LinkDefinition(),
+ _Paragraph(),
+ ],
+ inlineSyntaxes: [
+ BusyDollarMathSyntax(),
+ if (mode == MarkdownMode.writersideMarkdown)
+ BusyWritersideMathSyntax(),
+ _InlineHtml(),
+ _Link(),
+ _Image(),
+ _Autolink(),
+ ...md.ExtensionSet.gitHubWeb.inlineSyntaxes.where(
+ (syntax) => syntax is! md.InlineHtmlSyntax,
+ ),
+ ],
+ extensionSet: md.ExtensionSet.none,
+ withDefaultBlockSyntaxes: false,
+ );
+
+ final String filePath;
+ final String source;
+ final MarkdownMode mode;
+ final _lineMaps = Map.identity();
+ final _contexts = <_BlockContext>[];
+ final _locations = Map.identity();
+ final _html = Map.identity();
+ final _inlineHtml = Map.identity();
+ final _urls = Map.identity();
+ _LocatedText? _inlineSource;
+
+ List diagnose() {
+ final start = frontMatterEndOffset(source);
+ final lines = [];
+ final offsets = [];
+ var offset = start;
+ for (final match in RegExp(
+ r'[^\r\n]*(?:\r\n|\r|\n|$)',
+ ).allMatches(source.substring(start))) {
+ final raw = match[0]!;
+ lines.add(md.Line(raw.replaceFirst(RegExp(r'[\r\n]+$'), '')));
+ offsets.add(offset);
+ offset += raw.length;
+ }
+ final parser = md.BlockParser(lines, this);
+ _lineMaps[parser] = _Lines(lines, offsets);
+ final nodes = parser.parseLines();
+ final diagnostics = [];
+ final inlineNodes = Map>.identity();
+ void prepareInlines(md.Node node) {
+ if (node is md.UnparsedContent) {
+ final mapped = _locations[node];
+ if (mapped == null) return;
+ _inlineSource = mapped;
+ try {
+ inlineNodes[node] = parseInline(mapped.text);
+ } finally {
+ _inlineSource = null;
+ }
+ } else if (node is md.Element) {
+ for (final child in node.children ?? const []) {
+ prepareInlines(child);
+ }
+ }
+ }
+
+ // Footnote usage is known only after all inline content has been parsed.
+ // Match Document's omission of unused definitions from the rendered tree.
+ for (final node in nodes) {
+ prepareInlines(node);
+ }
+
+ void warn(_LocatedText text, int start, int end) {
+ final offsets = text.offsets;
+ final hasLocation =
+ offsets != null && start >= 0 && end > start && end <= offsets.length;
+ diagnostics.add(
+ Diagnostic(
+ code: 'markdown.raw-html.unsafe',
+ severity: DiagnosticSeverity.warning,
+ filePath: filePath,
+ // Location mapping is auxiliary to validation. If a source transform
+ // cannot be mapped, retain the warning on the file without inventing
+ // a location or preventing the Markdown document from being parsed.
+ sourceSpan: !hasLocation
+ ? null
+ : SourceSpan.fromOffsets(
+ filePath: filePath,
+ source: source,
+ startOffset: offsets[start],
+ endOffset: offsets[end - 1] + 1,
+ ),
+ ),
+ );
+ }
+
+ void warnHtmlTag(_LocatedText text, int? start, int? end) {
+ if (start == null || end == null) {
+ warn(text, 0, text.text.length);
+ return;
+ }
+ // HTML token spans can exclude '<' after a malformed prefix such as
+ // '<
'. Recover that adjacent character within this construct.
+ if (start > 0 && text.text[start] != '<' && text.text[start - 1] == '<') {
+ start--;
+ }
+ final name = RegExp(
+ r'?\s*[A-Za-z][A-Za-z0-9_-]*\b',
+ ).matchAsPrefix(text.text, start);
+ warn(text, start, name?.end ?? end);
+ }
+
+ void inspectInlineHtml(_LocatedText text) {
+ // Markdown already identified a single authored HTML construct. Tokenize
+ // it without a div-context tree, which would drop tags such as tr and td.
+ // This still uses HTML's attribute decoding and duplicate-name rules.
+ final tokens = html_parser.HtmlParser(
+ text.text,
+ generateSpans: true,
+ ).tokenizer;
+ while (tokens.moveNext()) {
+ final token = tokens.current;
+ if (token is! StartTagToken) continue;
+ final tag = token.name ?? '';
+ if (mode == MarkdownMode.writersideMarkdown && tag == 'video') continue;
+ if (isUnsafeHtmlTag(tag) ||
+ (isSafeHtmlTag(tag) &&
+ sanitizeHtmlAttributes(tag, token.data) == null)) {
+ warnHtmlTag(text, token.span?.start.offset, token.span?.end.offset);
+ }
+ }
+ }
+
+ void inspectHtml(_LocatedText text) {
+ if (text.text.isEmpty) return;
+ final fragment = html_parser.parseFragment(
+ text.text,
+ generateSpans: true,
+ );
+ var nodes = 0;
+ var exceededLimit = false;
+ void inspect(html.Node node, int depth) {
+ if (exceededLimit) return;
+ if (depth > maxRawHtmlDepth || nodes++ >= maxRawHtmlNodes) {
+ exceededLimit = true;
+ warn(text, 0, text.text.length);
+ return;
+ }
+ if (node is html.Element) {
+ final tag = node.localName ?? '';
+ if (mode == MarkdownMode.writersideMarkdown && tag == 'video') return;
+ if (isUnsafeHtmlTag(tag) ||
+ (isSafeHtmlTag(tag) &&
+ sanitizeHtmlAttributes(tag, node.attributes) == null)) {
+ final span = node.sourceSpan;
+ warnHtmlTag(text, span?.start.offset, span?.end.offset);
+ // One diagnostic covers the removed element and its descendants.
+ return;
+ }
+ }
+ for (final child in node.nodes) {
+ inspect(child, depth + 1);
+ }
+ }
+
+ inspect(fragment, 0);
+ }
+
+ void visit(md.Node node) {
+ if (_inlineHtml[node] case final inlineHtml?) {
+ inspectInlineHtml(inlineHtml);
+ } else if (_html[node] case final html?) {
+ inspectHtml(html);
+ } else if (node is md.UnparsedContent) {
+ for (final child in inlineNodes[node] ?? const []) {
+ visit(child);
+ }
+ } else if (node is md.Element) {
+ if (_urls[node] case final location?) {
+ final url = node.attributes[node.tag == 'img' ? 'src' : 'href'];
+ if (url != null &&
+ RegExp(
+ r'^(?:javascript|vbscript|data):',
+ caseSensitive: false,
+ ).hasMatch(url.trim())) {
+ warn(location, 0, location.text.length);
+ }
+ }
+ if (node.tag == 'pre' ||
+ node.tag == 'code' ||
+ node.tag == busyMarkMathInlineTag ||
+ node.tag == busyMarkMathBlockTag) {
+ return;
+ }
+ for (final child in node.children ?? const []) {
+ visit(child);
+ }
+ }
+ }
+
+ for (final node in nodes) {
+ if (node is md.Element &&
+ node.tag == 'li' &&
+ footnoteReferences[node.footnoteLabel] == 0) {
+ continue;
+ }
+ visit(node);
+ }
+ return diagnostics;
+ }
+
+ md.Node? track(
+ md.BlockParser parser,
+ md.Node? Function() parse, {
+ required bool html,
+ }) {
+ final lines = _lineMaps.putIfAbsent(parser, () {
+ // Containers strip only prefixes from their child lines. Bind each child
+ // line in order within its parent, never against another source block.
+ final parent = _contexts.last;
+ final offsets = [];
+ for (final child in parser.lines) {
+ while (parent.childLine < parent.lines.lines.length &&
+ !parent.lines.lines[parent.childLine].content.endsWith(
+ child.content,
+ )) {
+ parent.childLine++;
+ }
+ if (parent.childLine == parent.lines.lines.length) {
+ offsets.add(null);
+ continue;
+ }
+ final index = parent.childLine++;
+ final parentOffset = parent.lines.offsets[index];
+ offsets.add(
+ parentOffset == null
+ ? null
+ : parentOffset +
+ parent.lines.lines[index].content.length -
+ child.content.length,
+ );
+ }
+ return _Lines(parser.lines, offsets);
+ });
+ final start = lines.indices[parser.linesToConsume.first]!;
+ _contexts.add(_BlockContext(lines, start));
+ md.Node? node;
+ try {
+ node = parse();
+ } finally {
+ _contexts.removeLast();
+ }
+ if (node == null) return null;
+ final end = parser.isDone
+ ? lines.lines.length
+ : lines.indices[parser.current]!;
+ _LocatedText? block;
+ _LocatedText blockSource() => block ??= lines.text(start, end);
+ if (html) {
+ _html[node] = blockSource();
+ } else {
+ var cursor = 0;
+ void bind(md.Node node) {
+ if (_locations.containsKey(node)) return;
+ if (node is md.UnparsedContent) {
+ final raw = blockSource();
+ final mapped = raw.bind(node.textContent, cursor);
+ _locations[node] = mapped;
+ final mappedOffsets = mapped.offsets;
+ final rawOffsets = raw.offsets;
+ if (mappedOffsets != null &&
+ mappedOffsets.isNotEmpty &&
+ rawOffsets != null) {
+ while (cursor < rawOffsets.length &&
+ rawOffsets[cursor] <= mappedOffsets.last) {
+ cursor++;
+ }
+ }
+ } else if (node is md.Element) {
+ for (final child in node.children ?? const []) {
+ bind(child);
+ }
+ }
+ }
+
+ if (node is md.Element && node.tag == 'table') {
+ // Table syntax removes escaped pipes before parsing inline content.
+ // Bind by cell boundaries, including discarded and synthetic cells;
+ // decoded text must never be searched for across neighboring cells.
+ var line = start;
+ for (final section in node.children!.whereType()) {
+ for (final row in section.children!.whereType()) {
+ final cells = lines.text(line, line + 1).tableCells();
+ final elements = row.children!.whereType().toList();
+ for (var i = 0; i < elements.length; i++) {
+ for (final content
+ in elements[i].children!.whereType()) {
+ final cell = i < cells.length ? cells[i] : null;
+ _locations[content] = cell?.text == content.textContent
+ ? cell!
+ : _LocatedText(content.textContent, null);
+ }
+ }
+ line += line == start ? 2 : 1; // Skip the delimiter row.
+ }
+ }
+ } else {
+ bind(node);
+ }
+ }
+ return node;
+ }
+}
+
+class _Lines {
+ _Lines(this.lines, this.offsets)
+ : indices = Map.identity()
+ ..addEntries([
+ for (var i = 0; i < lines.length; i++) MapEntry(lines[i], i),
+ ]);
+ final List lines;
+ final List offsets;
+ final Map indices;
+
+ _LocatedText text(int start, int end) {
+ final text = StringBuffer();
+ List? positions = [];
+ for (var i = start; i < end; i++) {
+ if (i > start) {
+ text.write('\n');
+ final previousOffset = offsets[i - 1];
+ if (previousOffset == null) {
+ positions = null;
+ } else {
+ positions?.add(previousOffset + lines[i - 1].content.length);
+ }
+ }
+ text.write(lines[i].content);
+ final offset = offsets[i];
+ if (offset == null) {
+ positions = null;
+ } else {
+ positions?.addAll([
+ for (var j = 0; j < lines[i].content.length; j++) offset + j,
+ ]);
+ }
+ }
+ return _LocatedText(text.toString(), positions);
+ }
+}
+
+class _BlockContext {
+ _BlockContext(this.lines, this.childLine);
+ final _Lines lines;
+ int childLine;
+}
+
+class _LocatedText {
+ _LocatedText(this.text, this.offsets);
+ final String text;
+ final List? offsets;
+ _LocatedText slice(int start, int end) =>
+ _LocatedText(text.substring(start, end), offsets?.sublist(start, end));
+
+ List<_LocatedText> tableCells() {
+ final cells = <_LocatedText>[];
+ final sourceOffsets = offsets;
+ var buffer = StringBuffer();
+ var positions = [];
+ var index = 0;
+ void skipWhitespace() {
+ while (index < text.length &&
+ (text.codeUnitAt(index) == 0x20 || text.codeUnitAt(index) == 0x09)) {
+ index++;
+ }
+ }
+
+ void append(int offset) {
+ buffer.writeCharCode(text.codeUnitAt(offset));
+ if (sourceOffsets != null) positions.add(sourceOffsets[offset]);
+ }
+
+ void finishCell() {
+ final content = buffer.toString().trimRight();
+ cells.add(
+ _LocatedText(
+ content,
+ sourceOffsets == null ? null : positions.sublist(0, content.length),
+ ),
+ );
+ buffer = StringBuffer();
+ positions = [];
+ }
+
+ skipWhitespace();
+ if (index < text.length && text.codeUnitAt(index) == 0x7c) {
+ index++;
+ skipWhitespace();
+ }
+ while (index < text.length) {
+ final unit = text.codeUnitAt(index);
+ if (unit == 0x5c && index + 1 < text.length) {
+ // Like Markdown's TableSyntax, consume backslash pairs before testing
+ // delimiters. Only a backslash immediately escaping a pipe is removed.
+ if (text.codeUnitAt(index + 1) != 0x7c) append(index);
+ append(index + 1);
+ index += 2;
+ } else if (unit == 0x7c) {
+ finishCell();
+ index++;
+ skipWhitespace();
+ if (index == text.length) return cells;
+ } else {
+ append(index++);
+ }
+ }
+ finishCell();
+ return cells;
+ }
+
+ _LocatedText bind(String content, int cursor) {
+ // This is *unparsed* inline source within the block that produced it,
+ // before entity encoding or code-span normalization. Tables use their own
+ // source boundaries and transformation map above.
+ final start = text.indexOf(content, cursor);
+ return start < 0
+ ? _LocatedText(content, null)
+ : slice(start, start + content.length);
+ }
+}
+
+mixin _Track on md.BlockSyntax {
+ @override
+ md.Node? parse(md.BlockParser parser) =>
+ (parser.document as _LocatedDocument).track(
+ parser,
+ () => super.parse(parser),
+ html: this is md.HtmlBlockSyntax,
+ );
+}
+
+mixin _TrackNonNull on md.BlockSyntax {
+ @override
+ md.Node parse(md.BlockParser parser) =>
+ (parser.document as _LocatedDocument).track(
+ parser,
+ () => super.parse(parser),
+ html: this is md.HtmlBlockSyntax,
+ )!;
+}
+
+class _MathBlock extends BusyDisplayMathSyntax with _TrackNonNull {
+ const _MathBlock();
+}
+
+class _FencedCode extends md.FencedCodeBlockSyntax with _TrackNonNull {
+ const _FencedCode();
+}
+
+class _Empty extends md.EmptyBlockSyntax with _Track {
+ const _Empty();
+}
+
+class _Code extends md.CodeBlockSyntax with _TrackNonNull {
+ const _Code();
+}
+
+class _Rule extends md.HorizontalRuleSyntax with _TrackNonNull {
+ const _Rule();
+}
+
+class _LinkDefinition extends md.LinkReferenceDefinitionSyntax with _Track {
+ const _LinkDefinition();
+}
+
+class _Header extends md.HeaderWithIdSyntax with _TrackNonNull {
+ const _Header();
+}
+
+class _SetextHeader extends md.SetextHeaderWithIdSyntax with _TrackNonNull {
+ const _SetextHeader();
+}
+
+class _Table extends md.TableSyntax with _Track {
+ const _Table();
+}
+
+class _UnorderedList extends md.UnorderedListWithCheckboxSyntax
+ with _TrackNonNull {
+ const _UnorderedList();
+}
+
+class _OrderedList extends md.OrderedListWithCheckboxSyntax with _TrackNonNull {
+ const _OrderedList();
+}
+
+class _Footnote extends md.FootnoteDefSyntax with _Track {
+ const _Footnote();
+}
+
+class _Alert extends md.AlertBlockSyntax with _TrackNonNull {
+ const _Alert();
+}
+
+class _Blockquote extends md.BlockquoteSyntax with _TrackNonNull {
+ const _Blockquote();
+}
+
+class _HtmlBlock extends md.HtmlBlockSyntax with _TrackNonNull {
+ const _HtmlBlock();
+}
+
+class _Paragraph extends md.ParagraphSyntax with _Track {
+ const _Paragraph();
+}
+
+class _InlineHtml extends md.InlineHtmlSyntax {
+ @override
+ bool onMatch(md.InlineParser parser, Match match) {
+ final document = parser.document as _LocatedDocument;
+ // An element wrapper keeps this authored node separate from encoded prose
+ // when the Markdown parser combines adjacent text nodes.
+ final node = md.Element.text('busymark-authored-html', match[0]!);
+ document._inlineHtml[node] = document._inlineSource!.slice(
+ match.start,
+ match.end,
+ );
+ parser.addNode(node);
+ return true;
+ }
+}
+
+mixin _TrackLink on md.LinkSyntax {
+ @override
+ Iterable? close(
+ md.InlineParser parser,
+ md.SimpleDelimiter opener,
+ md.Delimiter? closer, {
+ String? tag,
+ required List Function() getChildren,
+ }) {
+ final nodes = super
+ .close(parser, opener, closer, tag: tag, getChildren: getChildren)
+ ?.toList();
+ final document = parser.document as _LocatedDocument;
+ if (nodes != null) {
+ final location = document._inlineSource!.slice(
+ opener.endPos - 1,
+ parser.pos + 1,
+ );
+ for (final node in nodes) {
+ document._urls[node] = location;
+ }
+ }
+ return nodes;
+ }
+}
+
+class _Link extends md.LinkSyntax with _TrackLink {}
+
+class _Image extends md.ImageSyntax with _TrackLink {}
+
+class _Autolink extends md.AutolinkSyntax {
+ @override
+ bool onMatch(md.InlineParser parser, Match match) {
+ final document = parser.document as _LocatedDocument;
+ final node = md.Element.text('a', match[1]!)
+ ..attributes['href'] = match[1]!;
+ document._urls[node] = document._inlineSource!.slice(
+ match.start,
+ match.end,
+ );
+ parser.addNode(node);
+ return true;
+ }
+}
diff --git a/lib/src/markdown/markdown_ast_adapter.dart b/lib/src/markdown/markdown_ast_adapter.dart
index b961f7f7..fe84956e 100644
--- a/lib/src/markdown/markdown_ast_adapter.dart
+++ b/lib/src/markdown/markdown_ast_adapter.dart
@@ -4,10 +4,12 @@ import 'package:xml/xml.dart';
import '../core/path_utils.dart';
import 'busymark_document.dart';
import 'markdown_fence.dart';
+import 'markdown_front_matter.dart';
import 'markdown_model.dart';
import 'math_syntax.dart';
import 'raw_html_adapter.dart';
import 'raw_html_policy.dart';
+import 'writerside_variable_syntax.dart';
import '../writerside/writerside_schema.dart';
const _rawHtmlAdapter = RawHtmlAdapter();
@@ -642,6 +644,16 @@ class MarkdownAstAdapter {
}
List _inlineFromNode(md.Node node) {
+ if (node is md.Element &&
+ node.attributes[writersideLiteralPercentAttribute] == 'true') {
+ return const [
+ BusyInline(
+ kind: BusyInlineKind.text,
+ text: '%',
+ attributes: {'ignore-vars': 'true'},
+ ),
+ ];
+ }
if (node is md.Text) {
if (node.text.isEmpty) {
return const [];
@@ -1381,17 +1393,11 @@ class MarkdownAstAdapter {
}
_FrontMatter? _extractFrontMatter(String source) {
- if (!source.startsWith('---\n') && source != '---') {
- return null;
- }
- final closing = RegExp(
- r'^---\s*$',
- multiLine: true,
- ).allMatches(source).skip(1).firstOrNull;
+ final closing = frontMatterClosing(source);
if (closing == null) {
return null;
}
- final end = closing.end;
+ final end = frontMatterEndOffset(source);
final raw = source.substring(0, end);
final body = source.substring(4, closing.start);
final values = {};
diff --git a/lib/src/markdown/markdown_front_matter.dart b/lib/src/markdown/markdown_front_matter.dart
new file mode 100644
index 00000000..40dcf6ad
--- /dev/null
+++ b/lib/src/markdown/markdown_front_matter.dart
@@ -0,0 +1,19 @@
+/// Supported metadata delimiters occupy an entire line. Horizontal whitespace
+/// and CRLF are accepted, but prefixes such as `---example` are ordinary text.
+final _delimiter = RegExp(r'^---[ \t]*\r?$', multiLine: true);
+
+bool hasFrontMatterOpening(String source) =>
+ _delimiter.matchAsPrefix(source) != null;
+
+RegExpMatch? frontMatterClosing(String source) {
+ if (!hasFrontMatterOpening(source)) return null;
+ return _delimiter.allMatches(source).skip(1).firstOrNull;
+}
+
+int frontMatterEndOffset(String source) {
+ final closing = frontMatterClosing(source);
+ if (closing == null) return 0;
+ return closing.end < source.length && source[closing.end] == '\n'
+ ? closing.end + 1
+ : closing.end;
+}
diff --git a/lib/src/markdown/markdown_parser.dart b/lib/src/markdown/markdown_parser.dart
index 6f890c62..ada4d3ff 100644
--- a/lib/src/markdown/markdown_parser.dart
+++ b/lib/src/markdown/markdown_parser.dart
@@ -10,8 +10,10 @@ import '../core/path_utils.dart';
import '../core/source_span.dart';
import '../core/uri_utils.dart';
import 'busymark_document.dart';
+import 'authored_html_diagnostics.dart';
import 'markdown_ast_adapter.dart';
import 'markdown_fence.dart';
+import 'markdown_front_matter.dart';
import 'markdown_model.dart';
import 'math_syntax.dart';
import 'raw_html_policy.dart';
@@ -46,6 +48,7 @@ class MarkdownParser {
MarkdownMode mode = MarkdownMode.commonMark,
String? workspaceRoot,
bool validateLocalReferences = true,
+ Map sourceOverrides = const {},
}) async {
final parsed =
source.length < _backgroundParseThresholdBytes ||
@@ -72,6 +75,7 @@ class MarkdownParser {
final diagnostics = sortDiagnostics([
...parsed.diagnostics,
...await _validateLocalReferencesAsync(
+ sourceOverrides: sourceOverrides,
filePath: filePath,
workspaceRoot: workspaceRoot,
headings: parsed.headings,
@@ -88,6 +92,23 @@ class MarkdownParser {
MarkdownMode mode = MarkdownMode.commonMark,
String? workspaceRoot,
bool validateLocalReferences = true,
+ }) => SourceLocationMapper.withSource(
+ source,
+ () => _parse(
+ filePath: filePath,
+ source: source,
+ mode: mode,
+ workspaceRoot: workspaceRoot,
+ validateLocalReferences: validateLocalReferences,
+ ),
+ );
+
+ ParsedMarkdownDocument _parse({
+ required String filePath,
+ required String source,
+ MarkdownMode mode = MarkdownMode.commonMark,
+ String? workspaceRoot,
+ bool validateLocalReferences = true,
}) {
final diagnostics = [];
final headings = [];
@@ -238,14 +259,6 @@ class MarkdownParser {
lineOffset: offset,
xmlBlocks: xmlBlocks,
);
- _extractUnsafeHtml(
- filePath: filePath,
- source: source,
- line: line,
- lineOffset: offset,
- mode: mode,
- diagnostics: diagnostics,
- );
if (inRawHtmlBlock) {
previousSetextCandidateLine = null;
@@ -265,6 +278,7 @@ class MarkdownParser {
lineIndex += 1;
}
+ _extractAuthoredHtmlDiagnostics(filePath, source, mode, diagnostics);
final renderedDocument = astAdapter.parse(
filePath: filePath,
source: source,
@@ -411,7 +425,10 @@ class MarkdownParser {
}
for (final block in _walkBlocks(document.blocks)) {
- if (block.kind != BusyBlockKind.table) {
+ // Table containers and their rows share the same block kind. Rows carry
+ // the `header` attribute, so do not validate them as independent tables.
+ if (block.kind != BusyBlockKind.table ||
+ block.attributes.containsKey('header')) {
continue;
}
final header = block.children.firstOrNull;
@@ -467,9 +484,27 @@ class MarkdownParser {
// the AST and the lossless source scanner. Preserve top-level heading
// spans independently: title and outline projection must not disappear
// merely because unrelated content falls back to protected source.
+ // Verbatim blocks (notably Writerside XML) also retain their own ranges;
+ // a missing range must not turn an XML fragment into the entire topic.
final headingSourceChunks = sourceChunks
.where(_isScannedHeadingSource)
.toList(growable: false);
+ final verbatimChunks = >{};
+ for (final chunk in modeledSourceChunks) {
+ verbatimChunks
+ .putIfAbsent(chunk.rawSource.trimRight(), () => [])
+ .add(chunk);
+ }
+ final verbatimOccurrences = {};
+ _ScannedBlockSource? nextVerbatimChunk(BusyBlock block) {
+ final raw = block.rawSource?.trimRight();
+ final matches = verbatimChunks[raw];
+ if (raw == null || matches == null) return null;
+ final index = verbatimOccurrences[raw] ?? 0;
+ verbatimOccurrences[raw] = index + 1;
+ return index < matches.length ? matches[index] : null;
+ }
+
var headingSourceIndex = 0;
final contentWithMetadata = [
for (final block in contentBlocks)
@@ -478,7 +513,7 @@ class MarkdownParser {
block.kind == BusyBlockKind.heading &&
headingSourceIndex < headingSourceChunks.length
? headingSourceChunks[headingSourceIndex++]
- : null,
+ : nextVerbatimChunk(block),
),
];
if (sourceChunks.any((chunk) => chunk.protectEdits)) {
@@ -1288,28 +1323,18 @@ class MarkdownParser {
return line.substring(start, end + 1).trimRight().endsWith('/>');
}
- int _frontMatterEndOffset(String source) {
- if (!source.startsWith('---')) {
- return 0;
- }
- final end = source.indexOf('\n---', 3);
- if (end == -1) {
- return 0;
- }
- final nextLine = source.indexOf('\n', end + 4);
- return nextLine == -1 ? source.length : nextLine + 1;
- }
+ int _frontMatterEndOffset(String source) => frontMatterEndOffset(source);
String? _frontMatterTitle(
String filePath,
String source,
List diagnostics,
) {
- if (!source.startsWith('---')) {
+ if (!hasFrontMatterOpening(source)) {
return null;
}
- final end = source.indexOf('\n---', 3);
- if (end == -1) {
+ final closing = frontMatterClosing(source);
+ if (closing == null) {
diagnostics.add(
Diagnostic(
code: 'markdown.front-matter.malformed',
@@ -1325,7 +1350,7 @@ class MarkdownParser {
);
return null;
}
- final block = source.substring(3, end);
+ final block = source.substring(3, closing.start);
for (final line in block.split('\n')) {
final match = RegExp(r'^\s*title\s*:\s*(.+?)\s*$').firstMatch(line);
if (match != null) {
@@ -1421,42 +1446,14 @@ class MarkdownParser {
);
}
- void _extractUnsafeHtml({
- required String filePath,
- required String source,
- required String line,
- required int lineOffset,
- required MarkdownMode mode,
- required List diagnostics,
- }) {
- if (mode == MarkdownMode.writersideMarkdown &&
- RegExp(
- r'^\s{0,3}