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' 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})', - caseSensitive: false, - ).hasMatch(line)) { - return; - } - if (!hasUnsafeHtml(line)) { - return; - } - final unsafe = - RegExp( - r' diagnostics, + ) { + diagnostics.addAll( + authoredHtmlDiagnostics(filePath: filePath, source: source, mode: mode), ); } @@ -1555,6 +1552,7 @@ class MarkdownParser { } Future> _validateLocalReferencesAsync({ + Map sourceOverrides = const {}, required String filePath, required String? workspaceRoot, required List headings, @@ -1563,6 +1561,25 @@ class MarkdownParser { }) async { final diagnostics = []; final anchors = headings.map((item) => item.id).toSet(); + final hasFileTargets = links.any((link) { + final destination = link.destination.trim(); + return destination.isNotEmpty && + !hasUriScheme(destination) && + !destination.startsWith('#'); + }); + final canonicalSelf = + (hasFileTargets ? await _canonicalBufferPath(filePath) : null) ?? + p.normalize(p.absolute(filePath)); + final sources = {}; + for (final entry + in hasFileTargets + ? sourceOverrides.entries + : const >[]) { + sources[await _canonicalBufferPath(entry.key)] = entry.value; + } + final targetCache = {}; + final anchorCache = >{canonicalSelf: anchors}; + final bufferedPaths = {canonicalSelf, ...sources.keys}; for (final link in links) { final destination = link.destination.trim(); if (hasUriScheme(destination)) { @@ -1578,11 +1595,13 @@ class MarkdownParser { final decodedAnchor = anchor == null ? null : _decodeLocalReferenceAnchor(anchor); - final target = await _resolveLocalLinkTargetAsync( - filePath: filePath, - workspaceRoot: workspaceRoot, - targetPath: targetPath, - ); + final target = targetCache[targetPath] ??= + await _resolveLocalLinkTargetAsync( + filePath: filePath, + workspaceRoot: workspaceRoot, + targetPath: targetPath, + bufferedPaths: bufferedPaths, + ); if (targetPath.isNotEmpty && target.blocksTargetValidation) { diagnostics.add( Diagnostic( @@ -1616,13 +1635,18 @@ class MarkdownParser { continue; } try { - final targetSource = await File(target.path!).readAsString(); - final targetAnchors = parse( - filePath: target.path!, - source: targetSource, - workspaceRoot: workspaceRoot, - validateLocalReferences: false, - ).anchors; + var targetAnchors = anchorCache[target.path!]; + if (targetAnchors == null) { + final targetSource = + sources[target.path!] ?? await File(target.path!).readAsString(); + targetAnchors = (await parseAsync( + filePath: target.path!, + source: targetSource, + workspaceRoot: workspaceRoot, + validateLocalReferences: false, + )).anchors; + anchorCache[target.path!] = targetAnchors; + } if (!targetAnchors.contains(decodedAnchor)) { diagnostics.add( Diagnostic( @@ -1736,6 +1760,7 @@ class MarkdownParser { required String filePath, required String? workspaceRoot, required String targetPath, + Set bufferedPaths = const {}, }) async { if (targetPath.isEmpty) { return const _LocalLinkTarget.currentDocument(); @@ -1752,10 +1777,20 @@ class MarkdownParser { ? p.normalize(localTargetPath) : p.normalize(p.join(p.dirname(filePath), localTargetPath)); final absoluteTarget = p.normalize(p.absolute(resolved)); - final canonicalTarget = await _canonicalExistingPath(absoluteTarget); + var canonicalTarget = await _canonicalExistingPath(absoluteTarget); + if (canonicalTarget == null) { + final bufferPath = await _canonicalBufferPath(absoluteTarget); + if (bufferedPaths.contains(bufferPath)) canonicalTarget = bufferPath; + } if (canonicalTarget == null || !_isWithinDirectory(root, canonicalTarget)) { return const _LocalLinkTarget.blocked(); } + if (bufferedPaths.contains(canonicalTarget)) { + return _LocalLinkTarget.found( + path: canonicalTarget, + canValidateAnchors: isMarkdownPath(canonicalTarget), + ); + } FileSystemEntityType type; try { type = await FileSystemEntity.type(canonicalTarget, followLinks: false); @@ -1783,6 +1818,14 @@ class MarkdownParser { ); } + Future _canonicalBufferPath(String path) async { + final absolute = p.normalize(p.absolute(path)); + final existing = await _canonicalExistingPath(absolute); + if (existing != null) return existing; + final parent = await _canonicalExistingPath(p.dirname(absolute)); + return parent == null ? absolute : p.join(parent, p.basename(absolute)); + } + String? _canonicalWorkspaceRootSync(String? workspaceRoot) { if (workspaceRoot == null || workspaceRoot.trim().isEmpty) { return null; diff --git a/lib/src/markdown/math_syntax.dart b/lib/src/markdown/math_syntax.dart index b43e517c..3ae29ec0 100644 --- a/lib/src/markdown/math_syntax.dart +++ b/lib/src/markdown/math_syntax.dart @@ -1,6 +1,7 @@ import 'package:markdown/markdown.dart' as md; import 'markdown_model.dart'; +import 'writerside_variable_syntax.dart'; const busyMarkMathInlineTag = 'busymark-math-inline'; const busyMarkMathBlockTag = 'busymark-math-block'; @@ -30,6 +31,8 @@ md.Document busyMarkMarkdownDocument(MarkdownMode mode) { return md.Document( blockSyntaxes: const [BusyDisplayMathSyntax()], inlineSyntaxes: [ + if (mode == MarkdownMode.writersideMarkdown) + WritersideLiteralPercentSyntax(), BusyDollarMathSyntax(), if (mode == MarkdownMode.writersideMarkdown) BusyWritersideMathSyntax(), ], diff --git a/lib/src/markdown/raw_html_policy.dart b/lib/src/markdown/raw_html_policy.dart index dfdb973a..f8be6e2d 100644 --- a/lib/src/markdown/raw_html_policy.dart +++ b/lib/src/markdown/raw_html_policy.dart @@ -347,3 +347,13 @@ bool _isSafeRelativeHtmlPath(String value) { } String _normalizeName(String name) => name.trim().toLowerCase(); + +/// Authored diagnostics only inspect actual HTML constructs. The renderer keeps +/// using the more conservative `hasUnsafeHtml` policy above. +bool hasUnsafeAuthoredHtml(String content) { + try { + return _hasUnsafeHtmlNode(html_parser.parseFragment(content)); + } on Object { + return true; + } +} diff --git a/lib/src/markdown/writerside_variable_syntax.dart b/lib/src/markdown/writerside_variable_syntax.dart new file mode 100644 index 00000000..c2d681cd --- /dev/null +++ b/lib/src/markdown/writerside_variable_syntax.dart @@ -0,0 +1,19 @@ +import 'package:markdown/markdown.dart' as md; + +const writersideLiteralPercentAttribute = 'data-writerside-literal-percent'; + +/// Preserve authored escapes until interpolation, after Markdown has decoded +/// entities and backslash escapes. An element keeps adjacent text from merging. +class WritersideLiteralPercentSyntax extends md.InlineSyntax { + WritersideLiteralPercentSyntax() + : super(r'&#(?:0*37|[xX]0*25);|%|\\%'); + + @override + bool onMatch(md.InlineParser parser, Match match) { + parser.addNode( + md.Element('span', [md.Text('%')]) + ..attributes[writersideLiteralPercentAttribute] = 'true', + ); + return true; + } +} diff --git a/lib/src/platform/header_bar_configuration.dart b/lib/src/platform/header_bar_configuration.dart index db0333fd..0ad1759a 100644 --- a/lib/src/platform/header_bar_configuration.dart +++ b/lib/src/platform/header_bar_configuration.dart @@ -32,6 +32,9 @@ class HeaderBarLabels { required this.back, required this.backShortcut, required this.save, + required this.export, + required this.exportShortcut, + required this.exportGtkAccelerator, required this.fullScreen, required this.fullScreenShortcut, required this.fullScreenGtkAccelerator, @@ -70,6 +73,9 @@ class HeaderBarLabels { final String back; final String backShortcut; final String save; + final String export; + final String exportShortcut; + final String exportGtkAccelerator; final String fullScreen; final String fullScreenShortcut; final String fullScreenGtkAccelerator; @@ -108,6 +114,9 @@ class HeaderBarLabels { 'back': back, 'backShortcut': backShortcut, 'save': save, + 'export': export, + 'exportShortcut': exportShortcut, + 'exportGtkAccelerator': exportGtkAccelerator, 'fullScreen': fullScreen, 'fullScreenShortcut': fullScreenShortcut, 'fullScreenGtkAccelerator': fullScreenGtkAccelerator, @@ -285,6 +294,8 @@ class HeaderBarConfiguration { required this.searchQuery, required this.textDirection, required this.canRefresh, + this.canExportPdf = false, + this.canExportHtml = false, required this.documentControlsVisible, required this.searchActive, required this.searchVisible, @@ -305,6 +316,8 @@ class HeaderBarConfiguration { final String searchQuery; final TextDirection textDirection; final bool canRefresh; + final bool canExportPdf; + final bool canExportHtml; final bool documentControlsVisible; final bool searchActive; final bool searchVisible; @@ -326,6 +339,8 @@ class HeaderBarConfiguration { String? searchQuery, TextDirection? textDirection, bool? canRefresh, + bool? canExportPdf, + bool? canExportHtml, bool? documentControlsVisible, bool? searchActive, bool? searchVisible, @@ -345,6 +360,8 @@ class HeaderBarConfiguration { searchQuery: searchQuery ?? this.searchQuery, textDirection: textDirection ?? this.textDirection, canRefresh: canRefresh ?? this.canRefresh, + canExportPdf: canExportPdf ?? this.canExportPdf, + canExportHtml: canExportHtml ?? this.canExportHtml, documentControlsVisible: documentControlsVisible ?? this.documentControlsVisible, searchActive: searchActive ?? this.searchActive, @@ -367,6 +384,8 @@ class HeaderBarConfiguration { 'searchQuery': searchQuery, 'textDirection': textDirection == TextDirection.rtl ? 'rtl' : 'ltr', 'canRefresh': canRefresh, + 'canExportPdf': canExportPdf, + 'canExportHtml': canExportHtml, 'documentControlsVisible': documentControlsVisible, 'searchActive': searchActive, 'searchVisible': searchVisible, @@ -387,6 +406,8 @@ class HeaderBarConfiguration { searchQuery == other.searchQuery && textDirection == other.textDirection && canRefresh == other.canRefresh && + canExportPdf == other.canExportPdf && + canExportHtml == other.canExportHtml && documentControlsVisible == other.documentControlsVisible && searchActive == other.searchActive && searchVisible == other.searchVisible && @@ -416,6 +437,8 @@ class HeaderBarConfiguration { searchQuery, textDirection, canRefresh, + canExportPdf, + canExportHtml, documentControlsVisible, searchActive, searchVisible, diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index fef1e7e5..98a4d12d 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -16,6 +16,7 @@ enum HeaderBarAction { search, refresh, save, + export, fullScreen, menu, settings, @@ -156,6 +157,10 @@ class LinuxHeaderBarService extends ChangeNotifier { return _invokeLegacy('setCanRefresh', value); } + Future setCanExportPdf(bool value) { + return _invokeLegacy('setCanExportPdf', value); + } + Future setDocumentControlsVisible(bool value) { return _invokeLegacy('setDocumentControlsVisible', value); } @@ -287,6 +292,8 @@ class LinuxHeaderBarService extends ChangeNotifier { ('setTitleRange', configuration.title), ('setViewMode', configuration.viewMode.name), ('setCanRefresh', configuration.canRefresh), + ('setCanExportPdf', configuration.canExportPdf), + ('setCanExportHtml', configuration.canExportHtml), ('setDocumentControlsVisible', configuration.documentControlsVisible), ('setSearchVisible', configuration.searchVisible), ('setSidebarVisible', configuration.sidebarVisible), @@ -386,6 +393,7 @@ class LinuxHeaderBarService extends ChangeNotifier { 'search' => HeaderBarAction.search, 'refresh' => HeaderBarAction.refresh, 'save' => HeaderBarAction.save, + 'export' => HeaderBarAction.export, 'fullScreen' => HeaderBarAction.fullScreen, 'menu' => HeaderBarAction.menu, 'settings' => HeaderBarAction.settings, diff --git a/lib/src/workspace/presentation/settings_screen.dart b/lib/src/workspace/presentation/settings_screen.dart index e439e624..b91ac60e 100644 --- a/lib/src/workspace/presentation/settings_screen.dart +++ b/lib/src/workspace/presentation/settings_screen.dart @@ -374,6 +374,7 @@ class _SettingsScreenState extends ConsumerState { case HeaderBarAction.search: case HeaderBarAction.refresh: case HeaderBarAction.save: + case HeaderBarAction.export: case HeaderBarAction.fullScreen: case HeaderBarAction.menu: case HeaderBarAction.viewModeEditor: @@ -396,6 +397,8 @@ class _SettingsScreenState extends ConsumerState { BusyMarkMainMenuAction action, ) { switch (action) { + case BusyMarkMainMenuAction.export: + return; case BusyMarkMainMenuAction.fullScreen: unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: diff --git a/lib/src/workspace/presentation/welcome_screen.dart b/lib/src/workspace/presentation/welcome_screen.dart index 11b90842..52470890 100644 --- a/lib/src/workspace/presentation/welcome_screen.dart +++ b/lib/src/workspace/presentation/welcome_screen.dart @@ -254,6 +254,7 @@ class _WelcomeScreenState extends ConsumerState { case HeaderBarAction.search: case HeaderBarAction.refresh: case HeaderBarAction.save: + case HeaderBarAction.export: case HeaderBarAction.fullScreen: case HeaderBarAction.menu: case HeaderBarAction.viewModeEditor: @@ -285,6 +286,8 @@ class _WelcomeScreenState extends ConsumerState { BusyMarkMainMenuAction action, ) { switch (action) { + case BusyMarkMainMenuAction.export: + return; case BusyMarkMainMenuAction.fullScreen: unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: diff --git a/lib/src/workspace/presentation/workspace_screen.dart b/lib/src/workspace/presentation/workspace_screen.dart index a26bb696..84124933 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -99,6 +99,23 @@ import '../workspace_tabs.dart'; import 'welcome_screen.dart'; import 'writerside_instance_dialog.dart'; +final _problemsPresentationProvider = + NotifierProvider<_ProblemsPresentation, bool>(_ProblemsPresentation.new); + +class _ProblemsPresentation extends Notifier { + @override + bool build() => false; + bool begin() { + if (state) return false; + state = true; + return true; + } + + void finish() { + if (ref.mounted) state = false; + } +} + final _outlineNavigationTargetProvider = NotifierProvider< _OutlineNavigationTargetController, @@ -779,6 +796,8 @@ class WorkspaceScreen extends ConsumerWidget { viewMode: _headerBarViewMode(settings.documentViewMode), searchQuery: searchState.query, canRefresh: true, + canExportPdf: canExportPdf, + canExportHtml: canExportHtml, documentControlsVisible: true, searchActive: searchState.active, searchVisible: true, @@ -972,6 +991,7 @@ class WorkspaceScreen extends ConsumerWidget { }, ), BusyMarkMainMenuButton( + canExport: canExportPdf || canExportHtml, onSelected: (action) => _handleMainMenuAction(context, ref, action), ), @@ -1109,6 +1129,8 @@ class WorkspaceScreen extends ConsumerWidget { unawaited(_validateActiveAndShowProblems(context, ref)); case HeaderBarAction.save: execute(BusyMarkCommandIds.save); + case HeaderBarAction.export: + execute(BusyMarkCommandIds.export); case HeaderBarAction.fullScreen: execute(BusyMarkCommandIds.fullScreen); case HeaderBarAction.settings: @@ -1158,6 +1180,8 @@ class WorkspaceScreen extends ConsumerWidget { BusyMarkMainMenuAction action, ) { switch (action) { + case BusyMarkMainMenuAction.export: + unawaited(exportWorkspace(context, ref)); case BusyMarkMainMenuAction.fullScreen: unawaited(ref.read(windowControlServiceProvider).toggleFullScreen()); case BusyMarkMainMenuAction.settings: @@ -1328,35 +1352,39 @@ class WorkspaceScreen extends ConsumerWidget { ); } - void _showProblemsDialog(BuildContext context, WidgetRef ref) { - final workspace = ref.read(workspaceControllerProvider).workspace; - if (workspace == null) { - return; - } + Future _showProblemsDialog(BuildContext context, WidgetRef ref) async { final headerBar = ref.read(linuxHeaderBarServiceProvider); - final count = workspace.allDiagnostics.length; - unawaited( - showBusyMarkModalDialog( - context, - headerBarService: headerBar.isAvailable ? headerBar : null, - builder: (context) => BusyMarkDialogShell( - title: context.l10n.problems, - maxWidth: BusyMarkSizes.contentWidth, - children: [ - Text( - context.l10n.diagnosticCount(count), - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: BusyMarkSurfaceColors.of(context).mutedForeground, + await showBusyMarkModalDialog( + context, + headerBarService: headerBar.isAvailable ? headerBar : null, + builder: (dialogContext) => Consumer( + builder: (context, ref, _) { + final workspace = ref.watch(workspaceControllerProvider).workspace; + return BusyMarkDialogShell( + title: context.l10n.problems, + maxWidth: BusyMarkSizes.contentWidth, + children: [ + Text( + context.l10n.diagnosticCount( + workspace?.allDiagnostics.length ?? 0, + ), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: BusyMarkSurfaceColors.of(context).mutedForeground, + ), ), - ), - const SizedBox(height: BusyMarkSpacing.md), - SizedBox( - width: BusyMarkSizes.problemsListWidth, - height: BusyMarkSizes.problemsListHeight, - child: _ProblemsList(workspace: workspace), - ), - ], - ), + const SizedBox(height: BusyMarkSpacing.md), + if (workspace != null) + SizedBox( + width: BusyMarkSizes.problemsListWidth, + height: BusyMarkSizes.problemsListHeight, + child: _ProblemsList( + workspace: workspace, + onRevealed: () => Navigator.of(dialogContext).pop(), + ), + ), + ], + ); + }, ), ); } @@ -1365,11 +1393,16 @@ class WorkspaceScreen extends ConsumerWidget { BuildContext context, WidgetRef ref, ) async { - await ref.read(workspaceControllerProvider.notifier).validateActive(); - if (!context.mounted) { - return; + final presentation = ref.read(_problemsPresentationProvider.notifier); + if (!presentation.begin()) return; + try { + final controller = ref.read(workspaceControllerProvider.notifier); + final outcome = await controller.validateActive(); + if (!context.mounted || !controller.isCurrentValidation(outcome)) return; + await _showProblemsDialog(context, ref); + } finally { + presentation.finish(); } - _showProblemsDialog(context, ref); } } @@ -10154,6 +10187,7 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { final _previewItemPositionsListener = ItemPositionsListener.create(); PreviewDocument? _outlineStopsPreview; List<_PositionedOutlineHeading> _previewOutlineStops = const []; + _SourceNavigationTarget? _lastSourceNavigationTarget; final _sourceEditorKey = GlobalKey(); final _previewBlockContexts = {}; String _lastPath = ''; @@ -10266,19 +10300,6 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { } _scrollToOutlineTarget(next); }); - ref.listen(_sourceNavigationTargetProvider, (previous, next) { - if (next == null) { - return; - } - if (next.filePath != widget.state.workspace?.activeFilePath) { - return; - } - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _sourceEditorKey.currentState?.scrollToLine(next.line); - } - }); - }); final searchTarget = ref.watch(_searchNavigationTargetProvider); if (searchTarget != null && ref.read(_workspaceSearchProvider).active && @@ -10304,6 +10325,22 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { final sourceVisible = widget.viewMode != DocumentViewModePreference.preview && !wysiwygVisible; + final sourceTarget = ref.watch(_sourceNavigationTargetProvider); + if (sourceVisible && + sourceTarget != null && + sourceTarget.filePath == widget.state.workspace?.activeFilePath && + !identical(sourceTarget, _lastSourceNavigationTarget)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || + sourceTarget.filePath != widget.state.workspace?.activeFilePath) { + return; + } + final editor = _sourceEditorKey.currentState; + if (editor == null) return; + editor.scrollToLine(sourceTarget.line); + _lastSourceNavigationTarget = sourceTarget; + }); + } final previewVisible = widget.viewMode != DocumentViewModePreference.source && !editorVisible; final standaloneDocumentLayout = BusyMarkDocumentLayoutSpec.standalone @@ -10480,6 +10517,8 @@ class _EditorPreviewSplitState extends ConsumerState<_EditorPreviewSplit> { expressionId: expressionId, code: code, sourceSpan: sourceSpan, + expectedRevision: activeBuffer?.revision, + expectedFilePath: activeBuffer?.filePath, ), ), ), @@ -12178,6 +12217,8 @@ class _PreviewBlockView extends ConsumerWidget { ref .read(workspaceControllerProvider.notifier) .updateMathRenderDiagnostic( + expectedRevision: editRevision, + expectedFilePath: workspace?.activeFilePath, expressionId: expressionId, code: code, sourceSpan: _previewMathSourceSpan(workspace, sourceBlock), @@ -12479,6 +12520,8 @@ class _PreviewInlineText extends ConsumerWidget { ref .read(workspaceControllerProvider.notifier) .updateMathRenderDiagnostic( + expectedRevision: editRevision, + expectedFilePath: workspace?.activeFilePath, expressionId: expressionId, code: code, sourceSpan: _previewMathSourceSpan(workspace, block), @@ -13756,9 +13799,10 @@ String _decodePreviewAnchor(String value) { } class _ProblemsList extends StatelessWidget { - const _ProblemsList({required this.workspace}); + const _ProblemsList({required this.workspace, required this.onRevealed}); final Workspace workspace; + final VoidCallback onRevealed; @override Widget build(BuildContext context) { @@ -13773,7 +13817,10 @@ class _ProblemsList extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: BusyMarkSpacing.xs), itemCount: diagnostics.length, itemBuilder: (context, index) { - return _DiagnosticRow(diagnostic: diagnostics[index]); + return _DiagnosticRow( + diagnostic: diagnostics[index], + onRevealed: onRevealed, + ); }, ), ); @@ -14553,9 +14600,10 @@ String _documentKindLabel(BuildContext context, DocumentKind kind) { } class _DiagnosticRow extends ConsumerWidget { - const _DiagnosticRow({required this.diagnostic}); + const _DiagnosticRow({required this.diagnostic, required this.onRevealed}); final Diagnostic diagnostic; + final VoidCallback onRevealed; @override Widget build(BuildContext context, WidgetRef ref) { @@ -14565,24 +14613,25 @@ class _DiagnosticRow extends ConsumerWidget { child: InkWell( hoverColor: busyMarkRowHoverColor(context), onTap: () async { - final opened = await ref - .read(workspaceControllerProvider.notifier) - .openActiveFile(diagnostic.filePath); - if (!opened || !context.mounted) { - return; - } - final line = diagnostic.line; - if (line != null) { + final controller = ref.read(workspaceControllerProvider.notifier); + final opened = await controller.openActiveFile(diagnostic.filePath); + if (!opened || !context.mounted) return; + controller.updateActiveEditorMode(DocumentViewModePreference.source); + unawaited( ref - .read(_sourceNavigationTargetProvider.notifier) - .set( - _SourceNavigationTarget( - filePath: diagnostic.filePath, - line: line, - ), - ); - } + .read(appSettingsControllerProvider.notifier) + .setDocumentViewMode(DocumentViewModePreference.source), + ); _clearGitDetailSelection(ref); + ref + .read(_sourceNavigationTargetProvider.notifier) + .set( + _SourceNavigationTarget( + filePath: diagnostic.filePath, + line: diagnostic.line ?? 1, + ), + ); + onRevealed(); }, child: Padding( padding: BusyMarkInsets.searchResultRow, diff --git a/lib/src/workspace/workspace_controller.dart b/lib/src/workspace/workspace_controller.dart index 781bbd0f..2c5d2708 100644 --- a/lib/src/workspace/workspace_controller.dart +++ b/lib/src/workspace/workspace_controller.dart @@ -169,6 +169,27 @@ class WorkspaceSearchRequestController extends Notifier { } } +enum ValidationStatus { published, stale, failed, busy, unavailable } + +class ValidationOutcome { + const ValidationOutcome({ + required this.status, + this.workspaceId, + this.filePath, + this.bufferId, + this.revision, + this.documentRevision, + }); + + final ValidationStatus status; + final String? workspaceId; + final String? filePath; + final String? bufferId; + final int? revision; + final int? documentRevision; + bool get published => status == ValidationStatus.published; +} + class WorkspaceController extends Notifier { static const _autoSaveDelay = Duration(milliseconds: 1500); @@ -188,6 +209,8 @@ class WorkspaceController extends Notifier { var _pendingPreviewRefresh = false; var _pendingOutlineRefresh = false; _ActivePreviewRevision? _activePreviewRevision; + var _manualValidationRunning = false; + var _validationSequence = 0; var _editRevision = 0; var _activeDocumentRevision = 0; var _workspaceRefreshRevision = 0; @@ -202,9 +225,14 @@ class WorkspaceController extends Notifier { required String expressionId, required String? code, SourceSpan? sourceSpan, + int? expectedRevision, + String? expectedFilePath, }) { final workspace = state.workspace; - if (workspace == null) { + if (workspace == null || + (expectedRevision != null && + (expectedRevision != editRevision || + expectedFilePath != workspace.activeFilePath))) { return; } final filePath = @@ -397,7 +425,11 @@ class WorkspaceController extends Notifier { activeFileSnapshot: active.diskSnapshot, openFilePaths: openPaths, ); - final reparsed = await _service.reparseActive(nextWorkspace, active.text); + final reparsed = await _reparseWithDocumentBuffers( + nextWorkspace, + active.text, + buffers: buffers, + ); state = WorkspaceState( workspace: reparsed, preview: _safePreview(reparsed, active.text), @@ -786,7 +818,10 @@ class WorkspaceController extends Notifier { final workspace = state.workspace!.copyWith( activeFileSnapshot: disk.snapshot, ); - final reparsed = await _service.reparseActive(workspace, disk.text); + final reparsed = await _reparseWithDocumentBuffers( + workspace, + disk.text, + ); if (state.activeBufferId == reloaded.id && state.activeBuffer?.revision == reloaded.revision) { state = state.copyWith( @@ -922,7 +957,7 @@ class WorkspaceController extends Notifier { revision: remapped.revision, source: remapped.text, )) { - final reparsed = await _service.reparseActive( + final reparsed = await _reparseWithDocumentBuffers( derivedWorkspace, remapped.text, ); @@ -1024,7 +1059,7 @@ class WorkspaceController extends Notifier { revision: reloaded.revision, source: reloaded.text, )) { - final workspace = await _service.reparseActive( + final workspace = await _reparseWithDocumentBuffers( derivedWorkspace.copyWith(activeFileSnapshot: disk.snapshot), disk.text, ); @@ -2109,7 +2144,11 @@ class WorkspaceController extends Notifier { state.workspace?.id != workspaceId) { return false; } - final reparsed = await _service.reparseActive(nextWorkspace, load.text); + final reparsed = await _reparseWithDocumentBuffers( + nextWorkspace, + load.text, + buffers: buffers, + ); if (!_isCurrentActiveDocumentOperation(operationRevision) || state.workspace?.id != workspaceId) { return false; @@ -2215,9 +2254,10 @@ class WorkspaceController extends Notifier { markdown: buffer.filePath == null ? workspace.markdown : null, ); var parsedBuffer = buffer; - var reparsed = await _service.reparseActive( + var reparsed = await _reparseWithDocumentBuffers( nextWorkspace, parsedBuffer.text, + buffers: documentBuffers, ); if (!_isCurrentActiveDocumentOperation(operationRevision)) { return false; @@ -2230,7 +2270,11 @@ class WorkspaceController extends Notifier { if (liveBuffer.revision != parsedBuffer.revision || liveBuffer.text != parsedBuffer.text) { parsedBuffer = liveBuffer; - reparsed = await _service.reparseActive(nextWorkspace, parsedBuffer.text); + reparsed = await _reparseWithDocumentBuffers( + nextWorkspace, + parsedBuffer.text, + buffers: documentBuffers, + ); if (!_isCurrentActiveDocumentOperation(operationRevision) || state.workspace?.id != workspaceId) { return false; @@ -2634,7 +2678,10 @@ class WorkspaceController extends Notifier { activeFileSnapshot: load.snapshot, openFilePaths: _openFileTabPaths(selected, path), ); - final reparsed = await _service.reparseActive(nextWorkspace, load.text); + final reparsed = await _reparseWithDocumentBuffers( + nextWorkspace, + load.text, + ); if (!_isCurrentActiveDocumentOperation(operationRevision)) { return false; } @@ -2777,12 +2824,14 @@ class WorkspaceController extends Notifier { } Future _drainDerivedRefreshes() async { - if (_derivedRefreshRunning) { + if (_derivedRefreshRunning || _manualValidationRunning) { return; } _derivedRefreshRunning = true; try { - while (ref.mounted && _derivedRefreshPending) { + while (ref.mounted && + _derivedRefreshPending && + !_manualValidationRunning) { _derivedRefreshPending = false; final rebuildPreview = _pendingPreviewRefresh; final refreshOutline = _pendingOutlineRefresh; @@ -2798,7 +2847,7 @@ class WorkspaceController extends Notifier { } } finally { _derivedRefreshRunning = false; - if (ref.mounted && _derivedRefreshPending) { + if (ref.mounted && _derivedRefreshPending && !_manualValidationRunning) { unawaited(_drainDerivedRefreshes()); } } @@ -2823,14 +2872,7 @@ class WorkspaceController extends Notifier { final editRevision = buffer.revision; final operationRevision = _activeDocumentRevision; try { - final overlaid = await _service.withWritersideSources(workspace, { - for (final buffer in state.documentBuffers) - if (buffer.filePath != null && - buffer.filePath != workspace.activeFilePath && - buffer.dirty) - buffer.filePath!: buffer.text, - }); - final reparsed = await _service.reparseActive(overlaid, text); + final reparsed = await _reparseWithDocumentBuffers(workspace, text); if (!_isCurrentActiveDocument( operationRevision, workspaceId: workspaceId, @@ -2870,14 +2912,7 @@ class WorkspaceController extends Notifier { final editRevision = state.activeBuffer?.revision ?? _editRevision; final operationRevision = _activeDocumentRevision; try { - final overlaid = await _service.withWritersideSources(workspace, { - for (final buffer in state.documentBuffers) - if (buffer.filePath != null && - buffer.filePath != workspace.activeFilePath && - buffer.dirty) - buffer.filePath!: buffer.text, - }); - final reparsed = await _service.reparseActive(overlaid, text); + final reparsed = await _reparseWithDocumentBuffers(workspace, text); final preview = await _service.buildPreviewAsync(reparsed, text); if (!_isCurrentActiveDocument( operationRevision, @@ -3072,14 +3107,7 @@ class WorkspaceController extends Notifier { return; } try { - final overlaid = await _service.withWritersideSources(workspace, { - for (final buffer in state.documentBuffers) - if (buffer.filePath != null && - buffer.filePath != workspace.activeFilePath && - buffer.dirty) - buffer.filePath!: buffer.text, - }); - final reparsed = await _service.reparseActive(overlaid, text); + final reparsed = await _reparseWithDocumentBuffers(workspace, text); final currentWorkspace = state.workspace; final currentBuffer = state.documentBuffers .where((buffer) => buffer.id == bufferId) @@ -3411,7 +3439,7 @@ class WorkspaceController extends Notifier { if (hasNewerEdits) { if (state.activeBufferId == savedBuffer.id && _settingsController.state.validateOnEdit) { - unawaited(_validateActive(rebuildPreview: _activeModeShowsPreview)); + _requestDerivedRefresh(rebuildPreview: _activeModeShowsPreview); } _scheduleAutoSave(savedBuffer.id); } else if (state.activeBufferId == savedBuffer.id) { @@ -4265,7 +4293,11 @@ class WorkspaceController extends Notifier { ); return activeBuffer == null ? Future.value(nextWorkspace.copyWith(markdown: null)) - : _service.reparseActive(nextWorkspace, activeBuffer.text); + : _reparseWithDocumentBuffers( + nextWorkspace, + activeBuffer.text, + buffers: buffers, + ); } Future _runWorkspaceFileOperation( @@ -4374,43 +4406,105 @@ class WorkspaceController extends Notifier { } } - Future validateActive() => _validateActive(rebuildPreview: true); + Future _reparseWithDocumentBuffers( + Workspace workspace, + String source, { + Iterable? buffers, + }) async { + final overlaid = await _service.withDocumentSources(workspace, { + for (final buffer in buffers ?? state.documentBuffers) + if (buffer.filePath != null) buffer.filePath!: buffer.text, + // Disk reloads and newly opened files may not yet be in the buffer list. + if (workspace.activeFilePath case final path?) path: source, + }); + return _service.reparseActive(overlaid, source); + } - Future _validateActive({required bool rebuildPreview}) async { + Future validateActive() async { + if (_manualValidationRunning) { + return const ValidationOutcome(status: ValidationStatus.busy); + } + _manualValidationRunning = true; + // The explicit request covers all edits queued so far. Running automatic + // work becomes stale; later edits can queue a refresh after this request. + _cancelPendingDerivedRefresh(); + try { + return await _validateActive(rebuildPreview: true); + } finally { + _manualValidationRunning = false; + if (ref.mounted && _derivedRefreshPending) { + unawaited(_drainDerivedRefreshes()); + } + } + } + + bool isCurrentValidation(ValidationOutcome outcome) => + outcome.published && + state.workspace?.id == outcome.workspaceId && + state.workspace?.activeFilePath == outcome.filePath && + state.activeBuffer?.id == outcome.bufferId && + editRevision == outcome.revision && + _activeDocumentRevision == outcome.documentRevision; + + Future _validateActive({ + required bool rebuildPreview, + }) async { final workspace = state.workspace; if (workspace == null) { - return; + return const ValidationOutcome(status: ValidationStatus.unavailable); } + final sequence = ++_validationSequence; + final buffers = List.of(state.documentBuffers); + final bufferId = state.activeBuffer?.id; final workspaceId = workspace.id; final activeFilePath = workspace.activeFilePath; final text = state.activeText; final editRevision = state.activeBuffer?.revision ?? _editRevision; final operationRevision = _activeDocumentRevision; + ValidationOutcome outcome(ValidationStatus status) => ValidationOutcome( + status: status, + workspaceId: workspaceId, + filePath: activeFilePath, + bufferId: bufferId, + revision: editRevision, + documentRevision: operationRevision, + ); + bool current() => + ref.mounted && + sequence == _validationSequence && + _isCurrentActiveDocument( + operationRevision, + workspaceId: workspaceId, + activeFilePath: activeFilePath, + ) && + state.activeText == text && + (state.activeBuffer?.revision ?? _editRevision) == editRevision && + buffers.length == state.documentBuffers.length && + buffers.every( + (buffer) => state.documentBuffers.any( + (live) => + live.id == buffer.id && + live.filePath == buffer.filePath && + live.revision == buffer.revision && + live.text == buffer.text, + ), + ); try { - final overlaid = await _service.withWritersideSources(workspace, { - for (final buffer in state.documentBuffers) - if (buffer.filePath != null && - buffer.filePath != workspace.activeFilePath && - buffer.dirty) - buffer.filePath!: buffer.text, - }); - final reparsed = await _service.reparseActive(overlaid, text); + final reparsed = await _reparseWithDocumentBuffers( + workspace, + text, + buffers: buffers, + ); final currentWorkspace = state.workspace; - if (!_isCurrentActiveDocument( - operationRevision, - workspaceId: workspaceId, - activeFilePath: activeFilePath, - ) || - currentWorkspace == null || - state.activeText != text || - state.activeBuffer?.revision != editRevision) { - return; + if (!current() || currentWorkspace == null) { + return outcome(ValidationStatus.stale); } final currentSnapshot = currentWorkspace.activeFileSnapshot; final validatedWorkspace = reparsed.copyWith( activeFileSnapshot: currentSnapshot, openFilePaths: currentWorkspace.openFilePaths, files: currentWorkspace.files, + runtimeDiagnostics: currentWorkspace.runtimeDiagnostics, ); if (rebuildPreview) { state = state.copyWith( @@ -4431,21 +4525,18 @@ class WorkspaceController extends Notifier { clearMessage: true, ); } + return outcome(ValidationStatus.published); } on Object catch (error) { - if (_isCurrentActiveDocument( - operationRevision, - workspaceId: workspaceId, - activeFilePath: activeFilePath, - ) && - state.activeText == text && - state.activeBuffer?.revision == editRevision) { + if (current()) { state = state.copyWith( message: WorkspaceMessage( WorkspaceMessageCode.validationFailed, error: error, ), ); + return outcome(ValidationStatus.failed); } + return outcome(ValidationStatus.stale); } } diff --git a/lib/src/workspace/workspace_model.dart b/lib/src/workspace/workspace_model.dart index 18df5516..b56c9b3e 100644 --- a/lib/src/workspace/workspace_model.dart +++ b/lib/src/workspace/workspace_model.dart @@ -98,6 +98,7 @@ class Workspace { required this.files, required this.diagnostics, this.runtimeDiagnostics = const [], + this.sourceOverrides = const {}, this.directories = const [], List openFilePaths = const [], this.activeFilePath, @@ -122,6 +123,9 @@ class Workspace { final List directories; final List diagnostics; final List runtimeDiagnostics; + + /// Current editor sources for a single validation snapshot. + final Map sourceOverrides; List get allDiagnostics => sortDiagnostics([...diagnostics, ...runtimeDiagnostics]); final ParsedMarkdownDocument? markdown; @@ -137,6 +141,7 @@ class Workspace { List? directories, List? diagnostics, List? runtimeDiagnostics, + Map? sourceOverrides, Object? markdown = _copyWithUnset, Object? writersideModule = _copyWithUnset, Object? writersideProject = _copyWithUnset, @@ -174,6 +179,7 @@ class Workspace { directories: directories ?? this.directories, diagnostics: diagnostics ?? this.diagnostics, runtimeDiagnostics: runtimeDiagnostics ?? this.runtimeDiagnostics, + sourceOverrides: sourceOverrides ?? this.sourceOverrides, markdown: nextMarkdown, writersideModule: nextWritersideModule, writersideProject: nextWritersideProject, diff --git a/lib/src/workspace/workspace_service.dart b/lib/src/workspace/workspace_service.dart index 51a1f9a0..2bf96a54 100644 --- a/lib/src/workspace/workspace_service.dart +++ b/lib/src/workspace/workspace_service.dart @@ -1,5 +1,3 @@ -import '../writerside/writerside_source_loader.dart'; -import '../writerside/writerside_reference_data.dart'; import 'dart:convert'; import 'dart:io'; @@ -18,7 +16,6 @@ import '../markdown/markdown_model.dart'; import '../markdown/markdown_parser.dart'; import '../markdown/preview_model.dart'; import '../writerside/writerside_module_service.dart'; -import '../writerside/writerside_parsers.dart'; import '../writerside/writerside_document_renderer.dart'; import '../writerside/writerside_document_resolver.dart'; import '../writerside/writerside_instance_service.dart'; @@ -1297,33 +1294,49 @@ class WorkspaceService { } } - Future withWritersideSources( + Future withDocumentSources( Workspace workspace, Map sources, ) async { + // This is a complete buffer snapshot. A missing entry has relinquished + // editor ownership and must be read from disk again, including dependencies. + sources = { + for (final entry in sources.entries) + normalizePath(entry.key): entry.value, + }; final project = workspace.writersideProject; - if (project == null) return workspace; + if (project == null) { + return workspace.copyWith(sourceOverrides: Map.unmodifiable(sources)); + } var updated = project; for (final module in project.modules) { final overrides = { for (final entry in sources.entries) - if (p.isWithin(module.rootPath, entry.key) && - module.sourceOverrides[entry.key] != entry.value) - entry.key: entry.value, + if (p.isWithin(module.rootPath, entry.key)) entry.key: entry.value, }; - if (overrides.isEmpty) continue; + if (overrides.length == module.sourceOverrides.length && + overrides.entries.every( + (entry) => module.sourceOverrides[entry.key] == entry.value, + )) { + continue; + } updated = updated.withModule( await writersideService.load( module.rootPath, options: _useWorkspaceScanOptionsForWriterside ? scanOptions : null, - sourceOverrides: {...module.sourceOverrides, ...overrides}, + sourceOverrides: overrides, ), ); } return workspace.copyWith( + sourceOverrides: Map.unmodifiable(sources), writersideProject: updated, writersideModule: updated.activeModule, - diagnostics: updated.diagnostics, + diagnostics: sortDiagnostics([ + ...updated.diagnostics, + for (final diagnostic in workspace.diagnostics) + if (!_isProjectDiagnostic(project, diagnostic)) diagnostic, + ]), ); } @@ -1333,41 +1346,32 @@ class WorkspaceService { return workspace; } if (workspace.kind == WorkspaceKind.writersideModule) { + final project = workspace.writersideProject; + final owner = project?.modulesByOrigin.entries + .where( + (entry) => + entry.value.topics.any( + (topic) => p.equals(topic.filePath, active), + ) || + _isWritersideProjectFile(entry.value, active), + ) + .firstOrNull; + if (owner != null && + !p.equals( + owner.value.rootPath, + workspace.writersideModule?.rootPath ?? '', + )) { + workspace = workspace.copyWith( + writersideModule: owner.value, + writersideProject: project!.withSelection(moduleId: owner.key), + ); + } final module = workspace.writersideModule; final topic = module?.topics .where((item) => item.filePath == active) .firstOrNull; - if (topic?.format == WritersideTopicFormat.markdown) { - final parsed = - WritersideTopicParser( - markdownParser: markdownParser, - documentParser: writersideService.topicParser.documentParser, - ).parseMarkdown( - filePath: active, - source: source, - topicsRoot: topic!.topicRoot, - ); - return _workspaceWithReparsedWritersideTopic( - workspace, - module!, - topic, - parsed, - ); - } - if (topic?.format == WritersideTopicFormat.xml) { - final parsed = writersideService.topicParser.parseXml( - filePath: active, - source: source, - topicsRoot: topic!.topicRoot, - ); - return _workspaceWithReparsedWritersideTopic( - workspace, - module!, - topic, - parsed, - ); - } - if (module != null && _isWritersideProjectFile(module, active)) { + if (module != null && + (topic != null || _isWritersideProjectFile(module, active))) { final updatedModule = await writersideService.load( module.rootPath, options: _useWorkspaceScanOptionsForWriterside ? scanOptions : null, @@ -1390,73 +1394,16 @@ class WorkspaceService { source: source, mode: MarkdownMode.commonMark, workspaceRoot: workspace.rootPath, + sourceOverrides: workspace.sourceOverrides, ); return workspace.copyWith( markdown: markdown, - diagnostics: markdown.diagnostics, - ); - } - - Future _workspaceWithReparsedWritersideTopic( - Workspace workspace, - WritersideModule module, - WritersideTopic previousTopic, - WritersideTopic parsedTopic, - ) async { - final activePath = parsedTopic.filePath; - var updatedModule = module.copyWith( - topics: List.unmodifiable([ - for (final topic in module.topics) - if (p.equals(topic.filePath, activePath)) parsedTopic else topic, - ]), - diagnostics: sortDiagnostics([ - for (final diagnostic in module.diagnostics) - if (!p.equals(diagnostic.filePath, activePath)) diagnostic, - ...parsedTopic.diagnostics, - ]), - sourceOverrides: { - ...module.sourceOverrides, - normalizePath(activePath): parsedTopic.document.source, - }, - ); - updatedModule = updatedModule.copyWith( - sourceFiles: await const WritersideSourceLoader().loadModule( - updatedModule, - ), - referenceData: await WritersideReferenceData.load(updatedModule), - ); - final previousProject = workspace.writersideProject; - final updatedProject = previousProject?.withModule(updatedModule); - final indexedWorkspace = workspace.copyWith( - markdown: parsedTopic.markdown, - writersideModule: updatedProject?.activeModule ?? updatedModule, - writersideProject: updatedProject, - ); - final resolutionDiagnostics = _writersideResolutionDiagnostics( - indexedWorkspace, - indexedWorkspace.writersideModule!, - parsedTopic, - ); - final previousResolutionDiagnostics = _writersideResolutionDiagnostics( - workspace, - module, - previousTopic, - ); - final preservedWorkspaceDiagnostics = [ - for (final diagnostic in workspace.diagnostics) - if (!_isProjectDiagnostic(previousProject, diagnostic) && - !p.equals(diagnostic.filePath, activePath) && - !previousResolutionDiagnostics.any( - (previous) => _sameDiagnostic(previous, diagnostic), - )) - diagnostic, - ]; - return indexedWorkspace.copyWith( diagnostics: sortDiagnostics([ - ...?updatedProject?.diagnostics, - if (updatedProject == null) ...updatedModule.diagnostics, - ...preservedWorkspaceDiagnostics, - ...resolutionDiagnostics, + for (final diagnostic in workspace.diagnostics) + if (!p.equals(diagnostic.filePath, active) || + !diagnostic.code.startsWith('markdown.')) + diagnostic, + ...markdown.diagnostics, ]), ); } @@ -1475,23 +1422,21 @@ class WorkspaceService { updatedModule, rediscoverFileSymbols: rediscoverFileSymbols, ); - final previousResolutionDiagnostics = [ - for (final topic in previousModule.topics) - ..._writersideResolutionDiagnostics(workspace, previousModule, topic), - ]; final preservedWorkspaceDiagnostics = [ for (final diagnostic in workspace.diagnostics) if (!_isProjectDiagnostic(previousProject, diagnostic) && !previousModule.diagnostics.any( (candidate) => identical(candidate, diagnostic), - ) && - !previousResolutionDiagnostics.any( - (previous) => _sameDiagnostic(previous, diagnostic), )) diagnostic, ]; return workspace.copyWith( - markdown: null, + markdown: updatedModule.topics + .where( + (topic) => p.equals(topic.filePath, workspace.activeFilePath ?? ''), + ) + .firstOrNull + ?.markdown, writersideModule: updatedProject?.activeModule ?? updatedModule, writersideProject: updatedProject, diagnostics: sortDiagnostics([ @@ -1544,14 +1489,6 @@ class WorkspaceService { ) ?? false; - bool _sameDiagnostic(Diagnostic first, Diagnostic second) { - return first.code == second.code && - first.severity == second.severity && - p.equals(first.filePath, second.filePath) && - first.sourceSpan?.startOffset == second.sourceSpan?.startOffset && - first.sourceSpan?.endOffset == second.sourceSpan?.endOffset; - } - PreviewDocument? buildPreview(Workspace workspace, String source) { final active = workspace.activeFilePath ?? workspace.markdown?.filePath; if (active == null) { @@ -1709,40 +1646,6 @@ class WorkspaceService { ); } - List _writersideResolutionDiagnostics( - Workspace workspace, - WritersideModule module, - WritersideTopic topic, - ) { - final instance = - workspace.writersideProject?.activeInstance ?? - module.instances - .where( - (candidate) => - !candidate.isLibrary && - candidate.topicFileSet.any( - (reference) => - module.topicByReference(reference)?.filePath == - topic.filePath, - ), - ) - .firstOrNull ?? - module.instances.where((candidate) => !candidate.isLibrary).firstOrNull; - return writersideDocumentResolver - .resolve( - topic.document, - WritersideResolveContext( - module: module, - topic: topic, - instance: instance, - modulesByOrigin: - workspace.writersideProject?.modulesByOrigin ?? - {if (module.config.moduleName case final name?) name: module}, - ), - ) - .diagnostics; - } - Future _openSingleMarkdown(String filePath) async { final load = await loadTextWithSnapshot(filePath); final rootPath = p.dirname(filePath); @@ -1873,18 +1776,7 @@ class WorkspaceService { .whereType() .firstOrNull, ); - final activeTopic = module.topics - .where((topic) => topic.filePath == firstTopic) - .firstOrNull; - if (activeTopic == null) { - return workspace; - } - return workspace.copyWith( - diagnostics: sortDiagnostics([ - ...workspace.diagnostics, - ..._writersideResolutionDiagnostics(workspace, module, activeTopic), - ]), - ); + return workspace; } WritersideProjectService get _writersideProjectService => diff --git a/lib/src/writerside/writerside_document.dart b/lib/src/writerside/writerside_document.dart index 838c0ac4..86107e92 100644 --- a/lib/src/writerside/writerside_document.dart +++ b/lib/src/writerside/writerside_document.dart @@ -8,6 +8,9 @@ const writersideSourceModuleRootAttribute = 'busymark-writerside-source-module-root'; const writersideSourceTopicPathAttribute = 'busymark-writerside-source-topic-path'; +const writersideSourceOccurrenceAttribute = + 'busymark-writerside-source-occurrence'; +const writersideFootnoteTargetAttribute = 'busymark-writerside-footnote-target'; bool writersideIgnorableRaw(String source) { final value = source.trim(); @@ -21,10 +24,14 @@ class WritersideSourceProvenance { const WritersideSourceProvenance({ required this.moduleRoot, required this.topicPath, + this.occurrence = 0, }); final String moduleRoot; final String topicPath; + + /// Distinguishes repeated includes within one resolved publication topic. + final int occurrence; } /// The source spelling of an XML attribute alongside its normalized semantic @@ -80,18 +87,20 @@ class WritersideDocument { /// The source representation is immaterial to reference identity. Markdown /// headings own the following blocks up to the next peer or ancestor heading. + /// Exact IDs take precedence over unambiguous Writerside-generated aliases. List? contentById(String id) { List? search( List siblings, + String targetId, ) { for (var i = 0; i < siblings.length; i++) { final node = siblings[i]; if (node is WritersideElementNode) { - if (node.attributes['id'] == id) return [node]; - final nested = search(node.children); + if (node.attributes['id'] == targetId) return [node]; + final nested = search(node.children, targetId); if (nested != null) return nested; } else if (node is WritersideMarkdownBlockNode) { - if (node.block.attributes['id'] == id) { + if (node.block.attributes['id'] == targetId) { if (node.block.kind != BusyBlockKind.heading) return [node]; final level = int.tryParse(node.block.attributes['level'] ?? '') ?? 1; @@ -110,7 +119,7 @@ class WritersideDocument { } List? searchBlocks(List blocks) { for (final block in blocks) { - if (block.attributes['id'] == id) { + if (block.attributes['id'] == targetId) { return [ WritersideMarkdownBlockNode( block: block, @@ -132,7 +141,29 @@ class WritersideDocument { return null; } - return search(nodes); + final exact = search(nodes, id); + if (exact != null) return exact; + + // Writerside's default LOWER_CASE_DASHES IDs replace punctuation with + // dashes, whereas existing BusyMark Markdown IDs remove it. Accept an + // unambiguous Writerside spelling without changing existing destinations + // or overriding explicit IDs. In particular, CMakeLists.txt is cmakelists-txt. + final aliases = walk().whereType().where(( + node, + ) { + final block = node.block; + return block.kind == BusyBlockKind.heading && + block.attributes['generatedId'] == 'true' && + block.plainText + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9-]+'), '-') + .replaceAll(RegExp(r'-{2,}'), '-') + .replaceAll(RegExp(r'^-+|-+$'), '') == + id; + }).toList(); + if (id.isEmpty || aliases.length != 1) return null; + final canonical = aliases.single.block.attributes['id']; + return canonical == null ? null : search(nodes, canonical); } WritersideDocument copyWith({ diff --git a/lib/src/writerside/writerside_document_parser.dart b/lib/src/writerside/writerside_document_parser.dart index 53442e4c..8f4898ca 100644 --- a/lib/src/writerside/writerside_document_parser.dart +++ b/lib/src/writerside/writerside_document_parser.dart @@ -46,10 +46,14 @@ class WritersideDocumentParser { for (final block in markdown.blocks) { final span = block.sourceSpan ?? SourceSpan.entireFile(filePath, source); final raw = - _safeSubstring(source, span.startOffset, span.endOffset) ?? + (block.sourceSpan == null + ? null + : _safeSubstring(source, span.startOffset, span.endOffset)) ?? block.rawSource ?? block.plainText; - if (_isSemanticXmlBlock(block, raw)) { + // Source-only blocks preserve editing/serialization data. They are not + // rendered content and may contain the entire protected Markdown source. + if (!block.isSourceOnly && _isSemanticXmlBlock(block, raw)) { try { final parsed = _parseXmlNodes( filePath: filePath, diff --git a/lib/src/writerside/writerside_document_renderer.dart b/lib/src/writerside/writerside_document_renderer.dart index 4e600266..e4109c5c 100644 --- a/lib/src/writerside/writerside_document_renderer.dart +++ b/lib/src/writerside/writerside_document_renderer.dart @@ -13,6 +13,22 @@ import 'writerside_source_loader.dart'; class WritersideDocumentRenderer { const WritersideDocumentRenderer(); + BusyDocument toBusyDocument( + WritersideDocument document, { + String? title, + bool includeTitleHeading = false, + int titleHeadingLevel = 1, + }) => _WritersideRenderSession().toBusyDocument( + document, + title: title, + includeTitleHeading: includeTitleHeading, + titleHeadingLevel: titleHeadingLevel, + ); +} + +class _WritersideRenderSession { + int _nextListOccurrence = 0; + BusyDocument toBusyDocument( WritersideDocument document, { String? title, @@ -944,6 +960,9 @@ class WritersideDocumentRenderer { }) { final type = list.attributes['type']?.trim().toLowerCase() ?? 'bullet'; final start = int.tryParse(list.attributes['start'] ?? '') ?? 1; + // Included copies retain their source spans. Allocate an ID for each + // rendered container so consecutive copies remain separate lists. + final listId = 'writerside-list-${_nextListOccurrence++}'; var itemNumber = start; final result = []; for (final child in list.children) { @@ -953,6 +972,7 @@ class WritersideDocumentRenderer { _listItemBlock( child, listType: type, + listId: listId, itemNumber: itemNumber++, headingLevel: headingLevel, ), @@ -969,6 +989,7 @@ class WritersideDocumentRenderer { required String listType, required int itemNumber, required int headingLevel, + String? listId, }) { final ordered = listType == 'decimal' || listType == 'alpha-lower'; final checkbox = listType == 'checkbox'; @@ -992,6 +1013,10 @@ class WritersideDocumentRenderer { 'ordered': '$ordered', 'marker': marker, 'listType': listType, + // Exporters need the numeric position and container independently of + // the display marker, which can be alphabetic or absent. + 'listOrdinal': '$itemNumber', + if (listId != null) 'listId': listId, if (listType == 'none') 'markerHidden': 'true', if (checkbox) 'task': item.attributes['checked'] ?? 'false', }, @@ -1178,6 +1203,7 @@ class WritersideDocumentRenderer { if (element.provenance case final provenance?) ...{ writersideSourceModuleRootAttribute: provenance.moduleRoot, writersideSourceTopicPathAttribute: provenance.topicPath, + writersideSourceOccurrenceAttribute: '${provenance.occurrence}', }, if (element is WritersideGenericElementNode) 'schemaKnown': '${element.schemaKnown}', @@ -1210,6 +1236,7 @@ class WritersideDocumentRenderer { ...inline.attributes, writersideSourceModuleRootAttribute: provenance.moduleRoot, writersideSourceTopicPathAttribute: provenance.topicPath, + writersideSourceOccurrenceAttribute: '${provenance.occurrence}', }, children: inline.children.map(annotateInline).toList(growable: false), ); @@ -1219,6 +1246,7 @@ class WritersideDocumentRenderer { ...block.attributes, writersideSourceModuleRootAttribute: provenance.moduleRoot, writersideSourceTopicPathAttribute: provenance.topicPath, + writersideSourceOccurrenceAttribute: '${provenance.occurrence}', }, inlines: block.inlines.map(annotateInline).toList(growable: false), children: block.children diff --git a/lib/src/writerside/writerside_document_resolver.dart b/lib/src/writerside/writerside_document_resolver.dart index 1fec08a8..f3060b83 100644 --- a/lib/src/writerside/writerside_document_resolver.dart +++ b/lib/src/writerside/writerside_document_resolver.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:html/parser.dart' as html; import 'package:path/path.dart' as p; import '../core/diagnostic.dart'; @@ -10,6 +11,52 @@ import 'writerside_schema.dart'; import 'writerside_source_loader.dart'; import 'writerside_code_selection.dart'; +/// Validates the same semantic documents consumed by preview and export. +/// A topic can participate in multiple instances; validate each applicable one. +List resolveWritersideModuleDiagnostics( + WritersideModule module, { + Map? modulesByOrigin, +}) { + final diagnostics = []; + final seen = {}; + for (final topic in module.topics) { + final instances = module.instances + .where( + (instance) => + !instance.isLibrary && + instance.topicFileSet.any( + (reference) => + module.topicByReference(reference)?.filePath == + topic.filePath, + ), + ) + .toList(); + for (final instance in [ + if (instances.isEmpty) null else ...instances, + ]) { + final resolved = const WritersideDocumentResolver().resolve( + topic.document, + WritersideResolveContext( + module: module, + topic: topic, + instance: instance, + modulesByOrigin: + modulesByOrigin ?? + {if (module.config.moduleName case final name?) name: module}, + ), + ); + for (final diagnostic in resolved.diagnostics) { + if (seen.add( + '${diagnostic.filePath}:${diagnostic.sourceSpan?.startOffset}:${diagnostic.code}:${diagnostic.args}', + )) { + diagnostics.add(diagnostic); + } + } + } + } + return sortDiagnostics(diagnostics); +} + class WritersideResolveContext { const WritersideResolveContext({ required this.module, @@ -53,11 +100,9 @@ class WritersideDocumentResolver { for (final variable in context.module.variables) if (state._matchesInstance(variable.instanceCondition, context.module)) variable.name: variable.value, - if (context.instance case final instance?) ...{ - 'instance': instance.name, - 'instance-lowercase': instance.name.toLowerCase(), - 'currentId': instance.id, - }, + 'instance': context.instance?.name ?? '', + 'instance-lowercase': context.instance?.name.toLowerCase() ?? '', + 'currentId': context.instance?.id ?? '', 'thisTopic': context.topic.id, }; final nodes = state.resolveNodes( @@ -84,6 +129,45 @@ class _ResolveState { final WritersideResolveContext context; final List diagnostics = []; final Set _unresolvedVariables = {}; + final Map> _footnoteTargets = {}; + var _nextSourceOccurrence = 0; + + Set _markdownFootnoteTargets(WritersideTopic topic) => + _footnoteTargets.putIfAbsent(topic.filePath, () { + final ids = {}; + void inlines(Iterable values) { + for (final value in values) { + if (value.attributes['id'] case final id? + when id.startsWith('fnref-')) { + ids.add(id); + } + inlines(value.children); + } + } + + void blocks(Iterable values) { + for (final block in values) { + if (block.attributes['html-footnotes'] case final source?) { + ids.addAll( + html + .parseFragment(source) + .querySelectorAll('li[id]') + .map((e) => e.id), + ); + } + inlines(block.inlines); + blocks(block.children); + } + } + + for (final node + in topic.document.nodes + .expand((node) => node.walk()) + .whereType()) { + blocks([node.block]); + } + return ids; + }); List resolveNodes( Iterable nodes, { @@ -94,6 +178,7 @@ class _ResolveState { required Set includeStack, required bool inheritedIgnoreVariables, Map arguments = const {}, + int sourceOccurrence = 0, }) { final scopedVariables = {...variables}; for (final node in nodes.whereType()) { @@ -120,6 +205,7 @@ class _ResolveState { final provenance = WritersideSourceProvenance( moduleRoot: module.rootPath, topicPath: topic.filePath, + occurrence: sourceOccurrence, ); final markdownChapters = <(int, bool)>[]; for (final node in nodes) { @@ -260,6 +346,7 @@ class _ResolveState { activeFilters: activeFilters, includeStack: includeStack, inheritedIgnoreVariables: ignoreVariables, + sourceOccurrence: sourceOccurrence, ); if (element.semanticKind == WritersideSemanticKind.api) { final reference = attributes['openapi-path'] ?? ''; @@ -429,6 +516,12 @@ class _ResolveState { attributes['anchor']?.replaceFirst(RegExp(r'^#'), '') ?? (hash < 0 ? '' : href.substring(hash + 1)); final destination = '$path${anchor.isEmpty ? '' : '#$anchor'}'; + var decodedAnchor = anchor; + try { + decodedAnchor = Uri.decodeComponent(anchor); + } on FormatException { + // Preserve malformed fragments so normal reference validation reports them. + } if (Uri.tryParse(path)?.hasScheme == true) { return { 'resolved-destination': destination, @@ -449,7 +542,15 @@ class _ResolveState { targetModule?.topicsById[path]; final target = anchor.isEmpty ? null - : targetTopic?.document.contentById(anchor)?.first; + : targetTopic?.document.contentById(decodedAnchor)?.first; + // Alias lookup must link to the ID that the renderer actually emits. + final resolvedAnchor = target is WritersideMarkdownBlockNode + ? (target.block.attributes['id'] == decodedAnchor + ? anchor + : Uri.encodeComponent( + target.block.attributes['id'] ?? decodedAnchor, + )) + : anchor; final instance = context.instance; var available = targetModule != null && @@ -600,7 +701,10 @@ class _ResolveState { : 'link-summary', ); final cardSummary = attributes['summary'] ?? summaryFor('card-summary'); - if (!available && attributes['nullable'] != 'true') { + if (!available && + attributes['nullable'] != 'true' && + !(targetTopic == null && + targetModule?.isUnparsedTopicReference(path) == true)) { _referenceDiagnostic( code: 'writerside.link.unavailable', node: node, @@ -615,8 +719,8 @@ class _ResolveState { ignore: false, ), 'resolved-destination': path.isEmpty - ? '#$anchor' - : '${targetTopic?.filePath ?? path}${anchor.isEmpty ? '' : '#$anchor'}', + ? '#$resolvedAnchor' + : '${targetTopic?.filePath ?? path}${resolvedAnchor.isEmpty ? '' : '#$resolvedAnchor'}', 'resolved-available': '$available', if (summary != null) 'summary': summary.trim(), if (cardSummary != null) 'card-summary': cardSummary, @@ -665,7 +769,7 @@ class _ResolveState { } final nullable = include.attributes['nullable'] == 'true'; if (targetTopic == null) { - if (!nullable) { + if (!nullable && !targetModule.isUnparsedTopicReference(from ?? '')) { _referenceDiagnostic( code: 'writerside.include.unresolved-source', node: include, @@ -748,6 +852,7 @@ class _ResolveState { topic: targetTopic, variables: includeVariables, arguments: arguments, + sourceOccurrence: ++_nextSourceOccurrence, activeFilters: filters.isEmpty ? null : filters, includeStack: includeStack, inheritedIgnoreVariables: inheritedIgnoreVariables, @@ -848,19 +953,54 @@ class _ResolveState { explicit == 'true' || (explicit != 'false' && (ignoreVariables || smart)); BusyInline resolveInline(BusyInline inline) { + final children = inline.children + .map(resolveInline) + .toList(growable: false); + final inlineIgnore = inline.attributes['ignore-vars'] == 'true' || ignore; final resolved = inline.copyWith( - text: _interpolate(inline.text, variables, sourceNode, ignore: ignore), + text: children.isEmpty + ? _interpolate( + inline.text, + variables, + sourceNode, + ignore: inlineIgnore, + ) + : children.map((child) => child.plainText).join(), destination: inline.destination == null ? null : _interpolate( inline.destination!, variables, sourceNode, - ignore: ignore, + ignore: inlineIgnore, ), - children: inline.children.map(resolveInline).toList(growable: false), + children: children, ); if (resolved.kind != BusyInlineKind.link) return resolved; + // Markdown's generated footnote targets live inside the retained HTML + // section/inline references, outside Writerside's element-ID index. + final destination = resolved.destination; + if (destination != null && destination.startsWith('#')) { + final raw = destination.substring(1); + final targets = _markdownFootnoteTargets(topic); + String? target = targets.contains(raw) ? raw : null; + try { + if (target == null) { + final decoded = Uri.decodeComponent(raw); + if (targets.contains(decoded)) target = decoded; + } + } on FormatException { + // Let normal link validation report an unusable authored destination. + } + if (target != null) { + return resolved.copyWith( + attributes: { + ...resolved.attributes, + writersideFootnoteTargetAttribute: target, + }, + ); + } + } final attributes = _resolveLink( {...resolved.attributes, 'href': resolved.destination ?? ''}, module: module, @@ -950,7 +1090,8 @@ class _ResolveState { ); } final key = '${node.span.filePath}:${node.span.startOffset}:$name'; - if (_unresolvedVariables.add(key)) { + if (_unresolvedVariables.add(key) && + context.module.variablesAvailable) { diagnostics.add( Diagnostic( code: 'writerside.variable.unresolved', diff --git a/lib/src/writerside/writerside_model.dart b/lib/src/writerside/writerside_model.dart index e71c6561..d4ae425a 100644 --- a/lib/src/writerside/writerside_model.dart +++ b/lib/src/writerside/writerside_model.dart @@ -525,14 +525,17 @@ class WritersideModule { required this.topics, required this.variables, required this.categories, - required this.diagnostics, + required List diagnostics, required this.validatedImageDirs, this.buildProfiles, this.instanceGroups, this.sourceOverrides = const {}, this.sourceFiles = const {}, this.referenceData = const WritersideReferenceData(), - }); + this.semanticDiagnostics = const [], + this.unparsedTopicReferences = const {}, + this.variablesAvailable = true, + }) : structuralDiagnostics = diagnostics; final String rootPath; final WritersideConfig config; @@ -540,7 +543,15 @@ class WritersideModule { final List topics; final List variables; final List categories; - final List diagnostics; + + /// Syntax, filesystem, and module configuration checks. Document semantics + /// are replaced separately whenever the project dependency snapshot changes. + final List structuralDiagnostics; + final List semanticDiagnostics; + final Set unparsedTopicReferences; + final bool variablesAvailable; + List get diagnostics => + sortDiagnostics([...structuralDiagnostics, ...semanticDiagnostics]); final List validatedImageDirs; final WritersideBuildProfilesConfig? buildProfiles; final WritersideInstanceGroupsConfig? instanceGroups; @@ -554,6 +565,7 @@ class WritersideModule { WritersideModule copyWith({ List? topics, List? diagnostics, + List? semanticDiagnostics, Map? sourceOverrides, Map? sourceFiles, WritersideReferenceData? referenceData, @@ -565,7 +577,10 @@ class WritersideModule { topics: topics ?? this.topics, variables: variables, categories: categories, - diagnostics: diagnostics ?? this.diagnostics, + diagnostics: diagnostics ?? structuralDiagnostics, + semanticDiagnostics: semanticDiagnostics ?? this.semanticDiagnostics, + unparsedTopicReferences: unparsedTopicReferences, + variablesAvailable: variablesAvailable, validatedImageDirs: validatedImageDirs, buildProfiles: buildProfiles, instanceGroups: instanceGroups, @@ -636,6 +651,14 @@ class WritersideModule { 'currentId', 'thisTopic', }; + + bool isUnparsedTopicReference(String reference) => + reference.isNotEmpty && + unparsedTopicReferences.any( + (candidate) => + candidate == _normalizedTopicReference(reference) || + p.basename(candidate) == p.basename(reference), + ); } String _normalizedTopicReference(String value) { diff --git a/lib/src/writerside/writerside_module_service.dart b/lib/src/writerside/writerside_module_service.dart index 69f771a2..0e631377 100644 --- a/lib/src/writerside/writerside_module_service.dart +++ b/lib/src/writerside/writerside_module_service.dart @@ -11,6 +11,7 @@ import '../core/path_utils.dart'; import '../core/source_span.dart'; import '../core/uri_utils.dart'; import 'writerside_model.dart'; +import 'writerside_document_resolver.dart'; import 'writerside_source_loader.dart'; import 'writerside_reference_data.dart'; import 'writerside_parsers.dart'; @@ -427,7 +428,7 @@ class WritersideModuleService { } final variables = []; - var validateVariables = true; + var variablesAvailable = true; if (variablesResolution?.type == FileSystemEntityType.file) { final file = File(variablesResolution!.path); if (await file.exists()) { @@ -439,7 +440,7 @@ class WritersideModuleService { sourceOverrides: normalizedOverrides, ); if (source == null) { - validateVariables = false; + variablesAvailable = false; } else { final parsed = variablesParser.parse(file.path, source); variables.addAll(parsed.$1); @@ -477,6 +478,8 @@ class WritersideModuleService { variables: variables, categories: categories, diagnostics: const [], + unparsedTopicReferences: Set.unmodifiable(unparsedTopics._fileNames), + variablesAvailable: variablesAvailable, validatedImageDirs: validatedImageDirs, buildProfiles: buildProfiles, instanceGroups: instanceGroups, @@ -487,25 +490,21 @@ class WritersideModuleService { module, validatedImageDirs, unparsedTopics: unparsedTopics, - validateVariables: validateVariables, validateCategories: validateCategories, ), ); - return WritersideModule( - rootPath: module.rootPath, - config: module.config, - instances: module.instances, - topics: module.topics, - variables: module.variables, - categories: module.categories, + final loaded = module.copyWith( diagnostics: sortDiagnostics(diagnostics), - validatedImageDirs: module.validatedImageDirs, - buildProfiles: module.buildProfiles, - instanceGroups: module.instanceGroups, - sourceOverrides: normalizedOverrides, sourceFiles: await const WritersideSourceLoader().loadModule(module), referenceData: await WritersideReferenceData.load(module), ); + return loaded.copyWith( + diagnostics: sortDiagnostics([ + ...loaded.structuralDiagnostics, + ...loaded.referenceData.diagnostics, + ]), + semanticDiagnostics: resolveWritersideModuleDiagnostics(loaded), + ); } Future _resolveConfigPath(String root) async { @@ -865,11 +864,9 @@ class WritersideModuleService { WritersideModule module, List validatedImageDirs, { required _UnparsedTopicIndex unparsedTopics, - required bool validateVariables, required bool validateCategories, }) { final diagnostics = []; - final variableNames = module.variableNames; final categoryIds = module.categories.map((item) => item.id).toSet(); final topicIds = {}; for (final topic in module.topics) { @@ -1045,69 +1042,6 @@ class WritersideModuleService { } duplicateIds[id.id] = id.span; } - if (validateVariables) { - for (final variable in topic.variables) { - if (!variableNames.contains(variable.name)) { - diagnostics.add( - Diagnostic( - code: 'writerside.variable.unresolved', - severity: DiagnosticSeverity.warning, - filePath: topic.filePath, - args: {'name': variable.name}, - sourceSpan: variable.span, - ), - ); - } - } - } - for (final link in topic.links) { - final destination = link.destination; - if (hasUriScheme(destination)) { - continue; - } - final parts = destination.split('#'); - final targetReference = parts.first; - final anchor = parts.length > 1 ? parts.sublist(1).join('#') : null; - final resolved = targetReference.isEmpty - ? _TopicResolution([topic]) - : _resolveTopicReference(module, targetReference, fromTopic: topic); - final target = resolved.topic; - if (target == null) { - if (resolved.isMissing && - unparsedTopics.matches(targetReference, fromTopic: topic)) { - continue; - } - diagnostics.add( - resolved.isAmbiguous - ? _ambiguousTopicReferenceDiagnostic( - reference: targetReference, - filePath: topic.filePath, - sourceSpan: link.span, - ) - : Diagnostic( - code: 'markdown.link.unresolved-target', - severity: DiagnosticSeverity.error, - filePath: topic.filePath, - args: {'destination': destination}, - sourceSpan: link.span, - ), - ); - } else if (anchor != null && anchor.isNotEmpty) { - final decodedAnchor = _decodeMarkdownAnchor(anchor); - final anchors = target.elementIds.map((item) => item.id).toSet(); - if (!anchors.contains(decodedAnchor)) { - diagnostics.add( - Diagnostic( - code: 'markdown.link.unresolved-anchor', - severity: DiagnosticSeverity.warning, - filePath: topic.filePath, - args: {'anchor': anchor, 'targetName': target.fileName}, - sourceSpan: link.span, - ), - ); - } - } - } for (final image in topic.images) { if (image.alt.trim().isEmpty) { diagnostics.add( @@ -1202,63 +1136,6 @@ class WritersideModuleService { ); } } - for (final include in topic.includes) { - if (include.from == null || include.from!.isEmpty) { - diagnostics.add( - Diagnostic( - code: 'writerside.include.unresolved-source', - severity: DiagnosticSeverity.error, - filePath: topic.filePath, - sourceSpan: include.span, - ), - ); - continue; - } - final resolved = _resolveTopicReference( - module, - include.from!, - fromTopic: topic, - ); - final target = resolved.topic; - if (target == null) { - if (resolved.isMissing && - unparsedTopics.matches(include.from!, fromTopic: topic)) { - continue; - } - if (!include.nullable) { - diagnostics.add( - resolved.isAmbiguous - ? _ambiguousTopicReferenceDiagnostic( - reference: include.from!, - filePath: topic.filePath, - sourceSpan: include.span, - ) - : Diagnostic( - code: 'writerside.include.unresolved-source', - severity: DiagnosticSeverity.error, - filePath: topic.filePath, - args: {'from': include.from}, - sourceSpan: include.span, - ), - ); - } - continue; - } - if (include.elementId != null && - !target.elementIds.any((item) => item.id == include.elementId)) { - if (!include.nullable) { - diagnostics.add( - Diagnostic( - code: 'writerside.include.unresolved-element', - severity: DiagnosticSeverity.error, - filePath: topic.filePath, - args: {'elementId': include.elementId, 'from': include.from}, - sourceSpan: include.span, - ), - ); - } - } - } if (validateCategories) { for (final category in topic.document.elements.where( (element) => element.name == 'category', @@ -1333,14 +1210,6 @@ class WritersideModuleService { return false; } - String _decodeMarkdownAnchor(String value) { - try { - return Uri.decodeComponent(value); - } on FormatException { - return value; - } - } - SourceSpan _stringSpan(String filePath, String source, String value) { final index = source.indexOf(value); return SourceSpan.fromOffsets( diff --git a/lib/src/writerside/writerside_parsers.dart b/lib/src/writerside/writerside_parsers.dart index 9d86b034..6f35381d 100644 --- a/lib/src/writerside/writerside_parsers.dart +++ b/lib/src/writerside/writerside_parsers.dart @@ -1032,6 +1032,19 @@ class WritersideTopicParser { required String filePath, required String source, required String topicsRoot, + }) => SourceLocationMapper.withSource( + source, + () => _parseMarkdown( + filePath: filePath, + source: source, + topicsRoot: topicsRoot, + ), + ); + + WritersideTopic _parseMarkdown({ + required String filePath, + required String source, + required String topicsRoot, }) { final parsed = markdownParser.parse( filePath: filePath, @@ -1144,6 +1157,15 @@ class WritersideTopicParser { required String filePath, required String source, String? topicsRoot, + }) => SourceLocationMapper.withSource( + source, + () => _parseXml(filePath: filePath, source: source, topicsRoot: topicsRoot), + ); + + WritersideTopic _parseXml({ + required String filePath, + required String source, + String? topicsRoot, }) { final semanticDocument = documentParser.parseXml( filePath: filePath, diff --git a/lib/src/writerside/writerside_project.dart b/lib/src/writerside/writerside_project.dart index e56470e4..edeea1da 100644 --- a/lib/src/writerside/writerside_project.dart +++ b/lib/src/writerside/writerside_project.dart @@ -6,6 +6,7 @@ import '../core/diagnostic.dart'; import '../core/path_utils.dart'; import '../core/source_span.dart'; import 'writerside_document.dart'; +import 'writerside_document_resolver.dart'; import 'writerside_model.dart'; import 'writerside_module_service.dart'; import 'writerside_schema.dart'; @@ -60,6 +61,7 @@ class WritersideReference { this.origin, this.sourceValue, this.scopeReference, + this.nullable = false, }); final String value; @@ -70,6 +72,7 @@ class WritersideReference { final String? origin; final String? sourceValue; final String? scopeReference; + final bool nullable; } class WritersideRenameEdit { @@ -270,6 +273,11 @@ class WritersideProjectIndex { } for (final requiredAttribute in WritersideSchema.requiredAttributesFor(element.name)) { + if (element.semanticKind == WritersideSemanticKind.include && + requiredAttribute == 'from') { + // An omitted source includes an element from the current topic. + continue; + } if (requiredAttribute == 'href' && element.attributes.containsKey('anchor')) { continue; @@ -483,10 +491,18 @@ class WritersideProjectIndex { symbol.kind == WritersideSymbolKind.snippet || symbol.kind == WritersideSymbolKind.variable, )) { + // Global variables share a module namespace; lexical scopes also belong + // to a specific source file, even when their offsets happen to match. + final scopedFile = + symbol.kind == WritersideSymbolKind.element || + symbol.kind == WritersideSymbolKind.snippet || + symbol.scopeSpan != null + ? symbol.filePath + : ''; groupedDefinitions .putIfAbsent( '${symbol.moduleId}:${symbol.kind.name}:' - '${symbol.kind == WritersideSymbolKind.element || symbol.kind == WritersideSymbolKind.snippet ? symbol.filePath : ''}:' + '$scopedFile:' '${symbol.name}:${symbol.instanceCondition ?? ''}:${symbol.scopeSpan?.startOffset ?? ''}', () => [], ) @@ -516,50 +532,9 @@ class WritersideProjectIndex { final modulesById = { for (final module in modules) moduleIds[module]!: module, }; - for (final reference in references.where( - (reference) => reference.kind == WritersideSymbolKind.snippet, - )) { - final targetModule = modulesById[reference.origin ?? reference.moduleId]; - if (targetModule == null) { - diagnostics.add( - _referenceDiagnostic('writerside.index.unresolved-origin', reference), - ); - continue; - } - final separator = reference.value.indexOf('#'); - final from = separator == -1 - ? reference.value - : reference.value.substring(0, separator); - final id = separator == -1 - ? null - : reference.value.substring(separator + 1); - final topics = from.isEmpty - ? targetModule.topics - .where((topic) => topic.filePath == reference.filePath) - .toList() - : targetModule.topicsMatchingReference(from); - if (topics.length != 1) { - diagnostics.add( - _referenceDiagnostic( - topics.isEmpty - ? 'writerside.index.unresolved-reference' - : 'writerside.index.ambiguous-reference', - reference, - ), - ); - continue; - } - if (id != null && - id.isNotEmpty && - topics.single.document.contentById(id) == null) { - diagnostics.add( - _referenceDiagnostic( - 'writerside.index.unresolved-reference', - reference, - ), - ); - } - } + // References retain authored values for navigation and source edits. Include + // diagnostics belong to the document resolver, which has the lexical + // variables, instance conditions, origins and optional-reference context. return WritersideProjectIndex( symbols: List.unmodifiable(symbols), @@ -940,13 +915,13 @@ class WritersideProject { if (!replaced) { return this; } - final nextModules = [ + final nextModules = _resolveProjectModules([ for (final candidate in modules) if (p.equals(candidate.rootPath, module.rootPath)) module else candidate, - ]; + ]); final nextIndex = WritersideProjectIndex.build( nextModules, fileSymbols: index.symbols.where( @@ -1030,10 +1005,11 @@ class WritersideProjectService { String? preferredModuleRoot, }) async { final roots = await discoverModuleRoots(projectRoot); - final modules = []; + var modules = []; for (final root in roots) { modules.add(await moduleService.load(root, options: scanOptions)); } + modules = _resolveProjectModules(modules); final index = WritersideProjectIndex.build( modules, fileSymbols: await _discoverFileSymbols(modules), @@ -1254,6 +1230,7 @@ void _collectElementReferences( WritersideReference( value: id == null ? from ?? '' : '${from ?? ''}#$id', kind: WritersideSymbolKind.snippet, + nullable: element.attributes['nullable'] == 'true', moduleId: moduleId, filePath: filePath, span: @@ -1366,15 +1343,6 @@ void _collectElementReferences( } } -Diagnostic _referenceDiagnostic(String code, WritersideReference reference) => - Diagnostic( - code: code, - severity: DiagnosticSeverity.warning, - filePath: reference.filePath, - args: {'reference': reference.value}, - sourceSpan: reference.span, - ); - String _moduleId(WritersideModule module) => module.config.moduleName?.trim().isNotEmpty == true ? module.config.moduleName!.trim() @@ -1444,3 +1412,18 @@ String _renamedReferenceValue( extension _FirstOrNull on Iterable { T? get firstOrNull => isEmpty ? null : first; } + +/// Every dependency replacement gets a fresh semantic pass for every topic, +/// including references from modules other than the one that changed. +List _resolveProjectModules(List modules) { + final origins = {for (final module in modules) _moduleId(module): module}; + return [ + for (final module in modules) + module.copyWith( + semanticDiagnostics: resolveWritersideModuleDiagnostics( + module, + modulesByOrigin: origins, + ), + ), + ]; +} diff --git a/lib/src/writerside/writerside_reference_data.dart b/lib/src/writerside/writerside_reference_data.dart index 7ed7ab50..e3c93ebd 100644 --- a/lib/src/writerside/writerside_reference_data.dart +++ b/lib/src/writerside/writerside_reference_data.dart @@ -1,6 +1,9 @@ import 'package:path/path.dart' as p; import 'package:xml/xml.dart'; +import '../core/diagnostic.dart'; +import '../core/source_span.dart'; + import 'writerside_model.dart'; import 'writerside_source_loader.dart'; @@ -11,6 +14,7 @@ class WritersideReferenceData { this.layouts = const {}, this.resources = const {}, this.sources = const {}, + this.diagnostics = const [], }); final Map glossary; @@ -19,10 +23,12 @@ class WritersideReferenceData { final Map> layouts; final Map resources; final Map sources; + final List diagnostics; static Future load(WritersideModule module) async { const loader = WritersideSourceLoader(); final sources = {}; + final diagnostics = []; Future xml(String path) async { final loaded = await loader.load( reference: path, @@ -34,7 +40,22 @@ class WritersideReferenceData { if (loaded.text == null) return null; try { return XmlDocument.parse(loaded.text!); - } on FormatException { + } on FormatException catch (error) { + final filePath = loaded.path ?? p.join(module.rootPath, path); + diagnostics.add( + Diagnostic( + code: 'writerside.reference-data.invalid-xml', + severity: DiagnosticSeverity.error, + filePath: filePath, + args: {'message': error.toString()}, + sourceSpan: SourceSpan.fromOffsets( + filePath: filePath, + source: loaded.text!, + startOffset: error.offset ?? 0, + endOffset: (error.offset ?? 0) + 1, + ), + ), + ); return null; } } @@ -118,6 +139,7 @@ class WritersideReferenceData { layouts: layouts, resources: resources, sources: sources, + diagnostics: sortDiagnostics(diagnostics), ); } } diff --git a/linux/io.busystack.busymark.metainfo.xml b/linux/io.busystack.busymark.metainfo.xml index 1d5e6c1c..4ae6f1b0 100644 --- a/linux/io.busystack.busymark.metainfo.xml +++ b/linux/io.busystack.busymark.metainfo.xml @@ -87,6 +87,7 @@ https://github.com/busystack/busymark/issues + diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index f3986dd6..5c34a766 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -109,6 +109,8 @@ struct _MyApplication { GtkWidget* search_entry; gboolean document_controls_visible; gboolean search_visible; + gboolean can_export_pdf; + gboolean can_export_html; GtkWidget* view_mode_box; GtkWidget* view_mode_button; GtkWidget* view_mode_icon; @@ -155,6 +157,8 @@ struct HeaderBarConfiguration { const gchar* title; const gchar* view_mode; gboolean can_refresh; + gboolean can_export_pdf; + gboolean can_export_html; gboolean document_controls_visible; gboolean search_active; gboolean search_visible; @@ -1329,6 +1333,9 @@ static GtkWidget* create_header_toggle_button(const gchar* icon_name) { } static const gchar* main_menu_icon_name(const gchar* action) { + if (g_strcmp0(action, "export") == 0) { + return "document-save-as-symbolic"; + } if (g_strcmp0(action, "settings") == 0) { return "preferences-system-symbolic"; } @@ -1380,6 +1387,11 @@ static void rebuild_main_menu_model(MyApplication* self, FlValue* labels) { return; } g_menu_remove_all(self->main_menu_model); + append_action_menu_item( + self->main_menu_model, + localized_label_or(labels, "export", ""), "header.export", + main_menu_icon_name("export"), + fl_lookup_string_arg(labels, "exportGtkAccelerator")); append_action_menu_item( self->main_menu_model, localized_label_or(labels, "fullScreen", ""), "header.full-screen", @@ -1536,8 +1548,23 @@ static void add_header_gaction(MyApplication* self, g_object_unref(action); } +static void set_header_action_enabled(MyApplication* self, + const gchar* action_name, + gboolean enabled) { + if (self->header_action_group == nullptr) { + return; + } + GAction* action = g_action_map_lookup_action( + G_ACTION_MAP(self->header_action_group), action_name); + if (action != nullptr && G_IS_SIMPLE_ACTION(action)) { + g_simple_action_set_enabled(G_SIMPLE_ACTION(action), enabled); + } +} + static void setup_header_actions(MyApplication* self) { self->header_action_group = g_simple_action_group_new(); + add_header_gaction(self, "export", "export"); + set_header_action_enabled(self, "export", FALSE); add_header_gaction(self, "settings", "settings"); add_header_gaction(self, "keyboard-shortcuts", "keyboardShortcuts"); add_header_gaction(self, "syntax-reference", "syntaxReference"); @@ -1854,6 +1881,10 @@ static gboolean decode_header_bar_configuration( configuration->sidebar_width <= 0 || !fl_lookup_optional_bool_arg(args, "canRefresh", &configuration->can_refresh) || + !fl_lookup_optional_bool_arg(args, "canExportPdf", + &configuration->can_export_pdf) || + !fl_lookup_optional_bool_arg(args, "canExportHtml", + &configuration->can_export_html) || !fl_lookup_optional_bool_arg( args, "documentControlsVisible", &configuration->document_controls_visible) || @@ -1898,6 +1929,10 @@ static void apply_header_bar_configuration( gtk_label_set_text(GTK_LABEL(self->title_label), configuration.title); } set_widget_sensitive(self->refresh_button, configuration.can_refresh); + self->can_export_pdf = configuration.can_export_pdf; + self->can_export_html = configuration.can_export_html; + set_header_action_enabled( + self, "export", self->can_export_pdf || self->can_export_html); set_sidebar_width(self, configuration.sidebar_width); set_text_direction(self, configuration.text_direction); set_sidebar_visible(self, configuration.sidebar_visible); @@ -2137,6 +2172,16 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, } else if (strcmp(method, "setCanRefresh") == 0) { set_widget_sensitive(self->refresh_button, fl_method_bool_arg(args)); respond_success(method_call); + } else if (strcmp(method, "setCanExportPdf") == 0) { + self->can_export_pdf = fl_method_bool_arg(args); + set_header_action_enabled( + self, "export", self->can_export_pdf || self->can_export_html); + respond_success(method_call); + } else if (strcmp(method, "setCanExportHtml") == 0) { + self->can_export_html = fl_method_bool_arg(args); + set_header_action_enabled( + self, "export", self->can_export_pdf || self->can_export_html); + respond_success(method_call); } else if (strcmp(method, "setDocumentControlsVisible") == 0) { set_document_controls_visible(self, fl_method_bool_arg(args)); respond_success(method_call); diff --git a/pubspec.yaml b/pubspec.yaml index 864833f2..3dc26ed3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymark description: Local-first Markdown and Writerside-compatible documentation editor. publish_to: 'none' -version: 0.4.0 +version: 0.4.1 environment: sdk: ">=3.12.1 <4.0.0" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index efe9580e..3a0ac4c1 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymark title: BusyMark -version: "0.4.0" +version: "0.4.1" summary: Markdown and Writerside documentation editor # Snap Store listing translations are managed outside this Flutter package. # Update store metadata when approved translated listing text is supplied. diff --git a/test/fixtures/writerside/list_numbering.topic b/test/fixtures/writerside/list_numbering.topic new file mode 100644 index 00000000..aef082bb --- /dev/null +++ b/test/fixtures/writerside/list_numbering.topic @@ -0,0 +1,18 @@ + + +
  • Charlie
  • +
  • Delta
  • +
    + +
  • First one
  • +
  • First two
  • +
    + +
  • Second one
  • +
  • Second two
  • +
    + + Step one + Step two + +
    diff --git a/test/fixtures/writerside/markdown_export_compatibility/guide.tree b/test/fixtures/writerside/markdown_export_compatibility/guide.tree new file mode 100644 index 00000000..10413d74 --- /dev/null +++ b/test/fixtures/writerside/markdown_export_compatibility/guide.tree @@ -0,0 +1,4 @@ + + + + diff --git a/test/fixtures/writerside/markdown_export_compatibility/images/.gitkeep b/test/fixtures/writerside/markdown_export_compatibility/images/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/test/fixtures/writerside/markdown_export_compatibility/images/.gitkeep @@ -0,0 +1 @@ + diff --git a/test/fixtures/writerside/markdown_export_compatibility/topics/main.md b/test/fixtures/writerside/markdown_export_compatibility/topics/main.md new file mode 100644 index 00000000..bf40df7f --- /dev/null +++ b/test/fixtures/writerside/markdown_export_compatibility/topics/main.md @@ -0,0 +1,32 @@ + + +# Export compatibility + +Before the list & after the metadata. + +1. First action + + A continuation paragraph. + + ```bash + echo '' + ``` + + - Nested action +2. Second action + +```xml +

    Repeated semantic note.

    +``` + +

    Repeated semantic note.

    + +Between the notes. + +

    Repeated semantic note.

    + +[Writerside heading](workflow.md#step-6-update-cmakelists-txt) + +[Existing Markdown heading](workflow.md#step-6-update-cmakeliststxt) + +Final compatibility paragraph. diff --git a/test/fixtures/writerside/markdown_export_compatibility/topics/workflow.md b/test/fixtures/writerside/markdown_export_compatibility/topics/workflow.md new file mode 100644 index 00000000..7d83d802 --- /dev/null +++ b/test/fixtures/writerside/markdown_export_compatibility/topics/workflow.md @@ -0,0 +1,9 @@ +# Project workflow + +## Step 6: Update CMakeLists.txt + +Retained CMake explanation. + +## After the CMake step + +Following chapter explanation. diff --git a/test/fixtures/writerside/markdown_export_compatibility/writerside.cfg b/test/fixtures/writerside/markdown_export_compatibility/writerside.cfg new file mode 100644 index 00000000..a47bd535 --- /dev/null +++ b/test/fixtures/writerside/markdown_export_compatibility/writerside.cfg @@ -0,0 +1 @@ + diff --git a/test/fixtures/writerside/pdf_front_matter/guide.tree b/test/fixtures/writerside/pdf_front_matter/guide.tree new file mode 100644 index 00000000..d089d270 --- /dev/null +++ b/test/fixtures/writerside/pdf_front_matter/guide.tree @@ -0,0 +1,4 @@ + + + diff --git a/test/fixtures/writerside/pdf_front_matter/topics/body.topic b/test/fixtures/writerside/pdf_front_matter/topics/body.topic new file mode 100644 index 00000000..4b362627 --- /dev/null +++ b/test/fixtures/writerside/pdf_front_matter/topics/body.topic @@ -0,0 +1,9 @@ + +

    BODY_START_SENTINEL is only in the document body.

    + +

    NESTED_BODY_SENTINEL stays linked.

    + +

    DEEP_BODY_SENTINEL stays present.

    +
    +
    +
    diff --git a/test/fixtures/writerside/pdf_front_matter/topics/hidden.topic b/test/fixtures/writerside/pdf_front_matter/topics/hidden.topic new file mode 100644 index 00000000..c4408dff --- /dev/null +++ b/test/fixtures/writerside/pdf_front_matter/topics/hidden.topic @@ -0,0 +1,3 @@ + +

    HIDDEN_BODY_SENTINEL remains in the document.

    +
    diff --git a/test/fixtures/writerside/pdf_front_matter/writerside.cfg b/test/fixtures/writerside/pdf_front_matter/writerside.cfg new file mode 100644 index 00000000..663759de --- /dev/null +++ b/test/fixtures/writerside/pdf_front_matter/writerside.cfg @@ -0,0 +1,4 @@ + + + + diff --git a/test/fixtures/writerside/repeated_list_include/guide.tree b/test/fixtures/writerside/repeated_list_include/guide.tree new file mode 100644 index 00000000..161afeb7 --- /dev/null +++ b/test/fixtures/writerside/repeated_list_include/guide.tree @@ -0,0 +1,3 @@ + + + diff --git a/test/fixtures/writerside/repeated_list_include/images/.gitkeep b/test/fixtures/writerside/repeated_list_include/images/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/test/fixtures/writerside/repeated_list_include/images/.gitkeep @@ -0,0 +1 @@ + diff --git a/test/fixtures/writerside/repeated_list_include/topics/library.topic b/test/fixtures/writerside/repeated_list_include/topics/library.topic new file mode 100644 index 00000000..f3dcc1c7 --- /dev/null +++ b/test/fixtures/writerside/repeated_list_include/topics/library.topic @@ -0,0 +1,8 @@ + + + +
  • Included first
  • +
  • Included second
  • +
    +
    +
    diff --git a/test/fixtures/writerside/repeated_list_include/topics/start.topic b/test/fixtures/writerside/repeated_list_include/topics/start.topic new file mode 100644 index 00000000..951f9e27 --- /dev/null +++ b/test/fixtures/writerside/repeated_list_include/topics/start.topic @@ -0,0 +1,4 @@ + + + + diff --git a/test/fixtures/writerside/repeated_list_include/writerside.cfg b/test/fixtures/writerside/repeated_list_include/writerside.cfg new file mode 100644 index 00000000..2ed785c1 --- /dev/null +++ b/test/fixtures/writerside/repeated_list_include/writerside.cfg @@ -0,0 +1,4 @@ + + + + diff --git a/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 81bef8b1..1cf5a2e2 100644 --- a/test/src/app_smoke_test.dart +++ b/test/src/app_smoke_test.dart @@ -272,12 +272,22 @@ void main() { await tester.pumpAndSettle(); expect(find.text(l10n.fullScreen), findsOneWidget); expect(find.text(BusyMarkAppShortcutLabels.fullScreen), findsOneWidget); + final exportMenuItem = find.byWidgetPredicate( + (widget) => + widget is BusyMarkPopupMenuItem && + widget.label == l10n.export, + ); + expect(exportMenuItem, findsOneWidget); + expect( + tester.widget>(exportMenuItem).enabled, + isFalse, + ); + expect(find.text(BusyMarkAppShortcutLabels.export), findsOneWidget); for (final documentCommand in { l10n.clipboardHistory, l10n.localHistory, l10n.findLocalHistoryEllipsis, l10n.generateOrUpdateMarkdownToc, - l10n.export, }) { expect(find.text(documentCommand), findsNothing); } @@ -2470,6 +2480,28 @@ void main() { ); expect(targetNode.children.single.topicFileName, 'loose.md'); + await openPopup(find.byTooltip(l10n.mainMenu)); + final mainMenuExportItem = find.byWidgetPredicate( + (widget) => + widget is BusyMarkPopupMenuItem && + widget.label == l10n.export, + ); + expect(mainMenuExportItem, findsOneWidget); + expect( + tester.widget>(mainMenuExportItem).enabled, + isTrue, + ); + await tester.tap(mainMenuExportItem); + for (var index = 0; index < 20; index++) { + await tester.pump(const Duration(milliseconds: 100)); + if (find.byType(BusyMarkModalEditorSurface).evaluate().isNotEmpty) { + break; + } + } + expect(find.byType(BusyMarkModalEditorSurface), findsOneWidget); + await tester.tap(find.text(l10n.cancel)); + await tester.pumpAndSettle(); + await openPopup(find.byTooltip(l10n.tocActions)); final exportItem = find.byWidgetPredicate( (widget) => @@ -10212,7 +10244,14 @@ class _MutableWorkspaceController extends WorkspaceController { WorkspaceState build() => initialState; @override - Future validateActive() async {} + Future validateActive() async => ValidationOutcome( + status: ValidationStatus.published, + workspaceId: state.workspace?.id, + filePath: state.workspace?.activeFilePath, + bufferId: state.activeBuffer?.id, + revision: editRevision, + documentRevision: 0, + ); @override Future selectWritersideContext({ diff --git a/test/src/asset_ingestion_service_test.dart b/test/src/asset_ingestion_service_test.dart index b3c7730e..7807ff1b 100644 --- a/test/src/asset_ingestion_service_test.dart +++ b/test/src/asset_ingestion_service_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:busymark/src/assets/asset_ingestion_service.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -105,6 +106,89 @@ void main() { expect(result.markdownPath, '../media/images/diagram.png'); }); + test( + 'restores retained Writerside video bytes without widening image paste', + () async { + final workspace = await Directory.systemTemp.createTemp( + 'busymark-video-assets-', + ); + addTearDown(() => workspace.delete(recursive: true)); + final document = File(p.join(workspace.path, 'topic.md')) + ..writeAsStringSync(''); + final video = Uint8List.fromList([ + 0, + 0, + 0, + 20, + ...ascii.encode('ftypmp42'), + 0, + 0, + 0, + 0, + ]); + const service = AssetIngestionService(); + final request = AssetIngestionRequest( + documentFilePath: document.path, + workspaceKind: AssetWorkspaceKind.standalone, + ); + + expect( + service.canIngestMediaBytes( + bytes: video, + suggestedFileName: 'clip.mp4', + ), + isTrue, + ); + final result = await service.ingestMediaBytes( + bytes: video, + suggestedFileName: 'clip.mp4', + request: request, + origin: AssetIngestionOrigin.clipboardImageFile, + ); + + expect(result.markdownPath, 'images/clip.mp4'); + expect(result.mimeType, 'video/mp4'); + expect(await File(result.absolutePath).readAsBytes(), video); + expect( + service.canIngestMediaBytes( + bytes: video, + suggestedFileName: 'clip.bin', + ), + isFalse, + ); + expect( + () => service.ingestMediaBytes( + bytes: video, + suggestedFileName: 'clip.bin', + request: request, + origin: AssetIngestionOrigin.clipboardImageFile, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'asset.invalid-media-type', + ), + ), + ); + expect( + () => service.ingestBytes( + bytes: video, + suggestedFileName: 'clip.mp4', + request: request, + origin: AssetIngestionOrigin.clipboardImageFile, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'asset.invalid-image-type', + ), + ), + ); + }, + ); + test('requires saving untitled documents and rejects non-images', () async { const service = AssetIngestionService(); const untitled = AssetIngestionRequest( diff --git a/test/src/authored_html_diagnostics_test.dart b/test/src/authored_html_diagnostics_test.dart new file mode 100644 index 00000000..0016cd1c --- /dev/null +++ b/test/src/authored_html_diagnostics_test.dart @@ -0,0 +1,237 @@ +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const script = ''; + const header = '| One | Two | Three |\n| --- | --- | --- |\n'; + test('escaped table pipes cannot bind across subsequent cells', () { + final parsed = const MarkdownParser().parse( + filePath: '/tmp/table.md', + source: '$header${r'| a\|b | a|b |'}\n', + ); + expect(parsed.diagnostics, isEmpty); + final table = parsed.busyDocument.blocks.singleWhere( + (block) => block.kind == BusyBlockKind.table, + ); + expect(table.children.last.children.map((cell) => cell.plainText), [ + 'a|b', + 'a', + 'b', + ]); + }); + + for (final row in [ + r'a\|b | a|b', + r'| a\|b\|c | a|b|c |', + r'| a\\\|b | a\\|b |', + r'| a\|b | a|b\', + r'| a\|b |', + r'| `a\|b` | a|b |', + ]) { + test('escaped table normalization preserves a later HTML cell: $row', () { + // A separate row also exercises missing, extra, and escaped delimiters + // without letting their source mapping affect subsequent cells. + final source = '$header$row\n| a\\|$script | a|$script |\n'; + final parsed = const MarkdownParser().parse( + filePath: '/tmp/table.md', + source: source, + ); + final warnings = parsed.diagnostics.where( + (d) => d.code == 'markdown.raw-html.unsafe', + ); + expect(warnings.map((d) => d.sourceSpan!.startOffset), [ + source.indexOf(script), + source.lastIndexOf(script), + ]); + }); + } + + test('escaped pipes in the table header retain diagnostic positions', () { + final source = + '| a\\|$script | a|$script |\n| --- | --- | --- |\n| a | b | c |\n'; + final parsed = const MarkdownParser().parse( + filePath: '/tmp/table.md', + source: source, + ); + final warnings = parsed.diagnostics.where( + (d) => d.code == 'markdown.raw-html.unsafe', + ); + expect(warnings.map((d) => d.sourceSpan!.startOffset), [ + source.indexOf(script), + source.lastIndexOf(script), + ]); + }); + + for (final row in [ + '| a\\|${script}b | a|${script}b |', + '| `$script` a\\|b | a|$script |', + ]) { + test('escaped table cells retain HTML positions: $row', () { + final source = '$header$row\n'; + final parsed = const MarkdownParser().parse( + filePath: '/tmp/table.md', + source: source, + ); + final warnings = parsed.diagnostics + .where((d) => d.code == 'markdown.raw-html.unsafe') + .toList(); + final starts = [ + if (!row.contains('`')) source.indexOf(script), + source.lastIndexOf(script), + ]; + expect(warnings.map((d) => d.sourceSpan!.startOffset), starts); + expect( + warnings.map((d) => d.sourceSpan!.endOffset), + starts.map((start) => start + ' d.sourceSpan!.startLine), everyElement(3)); + }); + } + + const tableHtml = '
    Cell
    '; + for (final prefix in ['', 'Text ']) { + test( + 'table HTML attributes are checked in ${prefix.isEmpty ? 'block' : 'inline'} context', + () { + final source = '$prefix$tableHtml\n'; + final parsed = const MarkdownParser().parse( + filePath: '/tmp/table.md', + source: source, + ); + final warning = parsed.diagnostics.singleWhere( + (d) => d.code == 'markdown.raw-html.unsafe', + ); + expect(warning.sourceSpan!.startOffset, source.indexOf('` then <$tag ONCLICK="example()">\n'; + final parsed = const MarkdownParser().parse( + filePath: '/tmp/table.md', + source: source, + ); + final warning = parsed.diagnostics.singleWhere( + (d) => d.code == 'markdown.raw-html.unsafe', + ); + expect(warning.sourceSpan!.startOffset, source.lastIndexOf('<$tag')); + expect( + warning.sourceSpan!.endOffset, + source.lastIndexOf('<$tag') + tag.length + 1, + ); + }); + } + + test('inline URL attributes use HTML entity decoding', () { + const source = 'Text Link\n'; + final parsed = const MarkdownParser().parse( + filePath: '/tmp/table.md', + source: source, + ); + final warning = parsed.diagnostics.singleWhere( + (d) => d.code == 'markdown.raw-html.unsafe', + ); + expect(warning.sourceSpan!.startOffset, source.indexOf('Cell\n', + ]) { + test('table HTML remains literal or safe: ${source.split('\n').first}', () { + final parsed = const MarkdownParser().parse( + filePath: '/tmp/table.md', + source: source, + ); + expect( + parsed.diagnostics.where((d) => d.code == 'markdown.raw-html.unsafe'), + isEmpty, + ); + }); + } + + for (final source in [ + '```html\n$script\n```\n\n$script\n', + ' $script\n\n$script\n', + '
    \n\n$script\n
    \n', + '
    \n *hello*\n <\n
    \n', + '[^unused]: $script\n\n\n$script\n', + '[^used]: $script\n\n\nSee [^used].\n', + '- ```html\n $script\n ```\n- $script\n', + '`$script` then $script\n', + '`` $script ``\n\n$script\n', + '> ```html\n> $script\n> ```\n>\n> $script\n', + '- Example\n\n $script\n\n $script\n', + '> - `$script` then $script\n', + '---\ntitle: "$script"\n---\n\n$script\n', + '```html\r\n$script\r\n```\r\n\r\n$script\r\n', + '| Code | HTML |\n| --- | --- |\n| `$script` | $script |\n', + '| One |\n| --- |\n| a | $script |\n| $script |\n', + '`a $script`\n\n## a $script\n', + ]) { + test( + 'HTML location follows parsed source: ${source.split('\n').first}', + () { + final parsed = const MarkdownParser().parse( + filePath: '/tmp/html.md', + source: source, + ); + final diagnostics = parsed.diagnostics.where( + (d) => d.code == 'markdown.raw-html.unsafe', + ); + expect(diagnostics, hasLength(1)); + final span = diagnostics.single.sourceSpan!; + final start = source.lastIndexOf(script); + expect(span.startOffset, start); + expect(span.endOffset, start + ' d.code == 'markdown.raw-html.unsafe', + ); + expect(diagnostic.sourceSpan!.startOffset, source.lastIndexOf(link)); + expect( + diagnostic.sourceSpan!.endOffset, + source.lastIndexOf(link) + link.length, + ); + }, + ); +} diff --git a/test/src/clipboard_history_panel_test.dart b/test/src/clipboard_history_panel_test.dart index 3f0ed33c..ea803e7c 100644 --- a/test/src/clipboard_history_panel_test.dart +++ b/test/src/clipboard_history_panel_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:busymark/l10n/generated/app_localizations.dart'; import 'package:busymark/l10n/generated/app_localizations_en.dart'; import 'package:busymark/src/app/app_settings.dart'; @@ -183,6 +185,35 @@ void main() { expect(selectedRect.bottom, lessThanOrEqualTo(listRect.bottom)); }); + testWidgets('Delete edits the focused search without removing history', ( + tester, + ) async { + final container = _container(); + container + .read(clipboardHistoryControllerProvider.notifier) + .retain( + const BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: 'retained entry', + sourceText: 'retained entry', + ), + ); + await _pumpPanel(tester, container); + + final field = tester.widget(find.byType(TextField)); + await tester.tap(find.byType(TextField)); + await tester.enterText(find.byType(TextField), 'retained'); + field.controller!.selection = const TextSelection.collapsed(offset: 6); + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.pump(); + + expect(field.controller!.text, 'retaind'); + expect( + container.read(clipboardHistoryControllerProvider).entries, + hasLength(1), + ); + }); + testWidgets('unsupported payload disables paste for the destination', ( tester, ) async { @@ -398,6 +429,94 @@ void main() { ); }, ); + + testWidgets('successful external paste retains after the panel is disposed', ( + tester, + ) async { + final clipboard = _PanelClipboard( + const RichClipboardData(text: 'delayed external', generation: 24), + ); + final container = _container(clipboard: clipboard); + final result = Completer(); + final target = _PanelInsertionTarget(resultFuture: result.future); + container.read(clipboardInsertionRegistryProvider).register(target); + await _pumpPanel(tester, container); + + await _openEntryActions(tester); + await tester.tap(find.text('Paste')); + await tester.pump(); + expect(target.pasteCalls, 1); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp(home: SizedBox()), + ), + ); + result.complete(ClipboardPasteResult.inserted); + await tester.pump(); + + expect( + container.read(clipboardHistoryControllerProvider).entries.single.text, + 'delayed external', + ); + }); + + testWidgets('plain source and Editor copies stay text and de-duplicate', ( + tester, + ) async { + final clipboard = _PanelClipboard( + const RichClipboardData( + text: 'source selection', + sourceText: 'source selection', + generation: 25, + ), + ); + final container = _container(clipboard: clipboard); + final history = container.read(clipboardHistoryControllerProvider.notifier); + history.retain( + const BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: 'source selection', + sourceText: 'source selection', + ), + ); + await _pumpPanel(tester, container); + + for (final (index, value) in [ + 'source selection', + 'Editor plain-text selection', + ].indexed) { + if (index > 0) { + history.clear(); + clipboard.value = RichClipboardData( + text: value, + sourceText: value, + generation: 25 + index, + ); + history.retain( + BusyMarkClipboardCapture( + kind: BusyMarkClipboardContentKind.text, + text: value, + sourceText: value, + ), + ); + await history.refreshCurrentClipboard(); + await tester.pump(); + } + expect( + container + .read(clipboardHistoryControllerProvider) + .currentClipboard! + .kind, + BusyMarkClipboardContentKind.text, + ); + expect( + find.byWidgetPredicate((widget) => widget is BusyMarkSidebarRecordRow), + findsOneWidget, + ); + } + }); } Future _openEntryActions(WidgetTester tester) async { @@ -462,12 +581,14 @@ class _PanelInsertionTarget this.normalPasteAvailable, this.plainTextPasteAvailable, this.result = ClipboardPasteResult.inserted, + this.resultFuture, }); final bool supportImages; final bool? normalPasteAvailable; final bool? plainTextPasteAvailable; final ClipboardPasteResult result; + final Future? resultFuture; int pasteCalls = 0; bool? lastPlainText; final payloads = []; @@ -501,7 +622,7 @@ class _PanelInsertionTarget pasteCalls++; lastPlainText = plainText; payloads.add(payload); - return result; + return resultFuture ?? result; } @override diff --git a/test/src/export_options_editor_test.dart b/test/src/export_options_editor_test.dart index 246ca8da..5acd7870 100644 --- a/test/src/export_options_editor_test.dart +++ b/test/src/export_options_editor_test.dart @@ -52,6 +52,29 @@ void main() { expect(await result, isNull); }); + testWidgets('Export keeps breathing room below its final option', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: ExportOptionsDialog( + pdf: PdfExportOptions(), + html: HtmlExportOptions(), + initialFormat: ExportFormat.pdf, + ), + ), + ), + ); + + final spacing = tester.widget( + find.byKey(const ValueKey('export-options-bottom-spacing')), + ); + expect(spacing.height, BusyMarkSpacing.lg); + }); + for (final pdf in [true, false]) { for (final writerside in [false, true]) { testWidgets( diff --git a/test/src/export_review_regression_test.dart b/test/src/export_review_regression_test.dart new file mode 100644 index 00000000..55a4b33c --- /dev/null +++ b/test/src/export_review_regression_test.dart @@ -0,0 +1,622 @@ +import 'dart:io'; + +import 'package:busymark/src/export/html_export_models.dart'; +import 'package:busymark/src/export/html_export_service.dart'; +import 'package:busymark/src/export/markdown_export_assets.dart'; +import 'package:busymark/src/export/markdown_export_document.dart'; +import 'package:busymark/src/export/markdown_export_mapper.dart'; +import 'package:busymark/src/export/markdown_pdf_export_service.dart'; +import 'package:busymark/src/export/markdown_pdf_models.dart'; +import 'package:busymark/src/export/markdown_visualization_export.dart'; +import 'package:busymark/src/export/typst_compiler.dart'; +import 'package:busymark/src/export/writerside_pdf_export_service.dart'; +import 'package:busymark/src/export/writerside_pdf_models.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/visualization/visualization_cache.dart'; +import 'package:busymark/src/visualization/visualization_coordinator.dart'; +import 'package:busymark/src/visualization/visualization_models.dart'; +import 'package:busymark/src/visualization/visualization_renderer.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:html/parser.dart' as html; +import 'package:path/path.dart' as p; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory root; + late String module; + final compiler = Platform.environment['BUSYMARK_TYPST_PATH']; + final pdfAvailable = + compiler != null && + File(compiler).existsSync() && + File('/usr/bin/pdftotext').existsSync(); + setUp(() async { + root = await Directory.systemTemp.createTemp('export-review-'); + module = p.join(root.path, 'Writerside'); + }); + tearDown(() => root.delete(recursive: true)); + Future put(String name, String source) async { + final file = File(p.join(root.path, name)); + await file.parent.create(recursive: true); + await file.writeAsString(source); + } + + Future configure(String nodes, {String extra = ''}) async { + await put( + 'Writerside/writerside.cfg', + '$extra', + ); + final start = RegExp(r'topic="([^"]+)"').firstMatch(nodes)?.group(1); + await put( + 'Writerside/guide.tree', + '$nodes', + ); + } + + Future compose() async { + final recording = _RecordingExporter(); + await WritersidePdfExportService(markdownExporter: recording).export( + WritersidePdfExportRequest( + moduleRoot: module, + projectRoot: root.path, + instanceId: 'guide', + destinationPath: p.join(root.path, 'out.pdf'), + overwrite: false, + ), + ); + return recording.document!; + } + + Future compileWriterside() async { + final path = p.join(root.path, 'footnotes.pdf'); + await WritersidePdfExportService( + markdownExporter: MarkdownPdfExportService( + compilerLocator: TypstCompilerLocator( + environment: {'BUSYMARK_TYPST_PATH': compiler!}, + ), + templateLoader: () => File('assets/export/markdown.typ').readAsString(), + ), + ).export( + WritersidePdfExportRequest( + moduleRoot: module, + projectRoot: root.path, + instanceId: 'guide', + destinationPath: path, + overwrite: false, + ), + ); + final extracted = await Process.run('/usr/bin/pdftotext', [ + '-layout', + path, + '-', + ]); + expect(extracted.exitCode, 0, reason: '${extracted.stderr}'); + return extracted.stdout as String; + } + + test( + 'Writerside PDF retains percent-encoded non-ASCII footnote labels', + () async { + await configure(''); + await put( + 'Writerside/topics/main.md', + '# Notes\n\nFirst[^café] and repeated[^café].\n\n[^café]: Encoded label definition retained.\n', + ); + final text = await compileWriterside(); + expect( + 'Encoded label definition retained.'.allMatches(text), + hasLength(1), + ); + expect(text, matches(RegExp(r'First\s*1\s*and repeated\s*1'))); + }, + skip: !pdfAvailable, + ); + + test('included footnotes use separate document-occurrence namespaces', () async { + await configure(''); + await put( + 'Writerside/topics/main.topic', + '', + ); + for (final name in ['a', 'b']) { + await put( + 'Writerside/topics/$name.md', + '# Included $name\n\nReference $name[^note] and repeated $name[^note].\n\n[^note]: Definition $name retained.\n', + ); + } + final document = const MarkdownExportMapper().map(await compose()); + final definitions = _exportBlocks( + document.blocks, + ).where((b) => b.attributes.containsKey('footnoteId')).toList(); + expect(definitions, hasLength(3)); + expect( + definitions.map((b) => b.attributes['footnoteId']).toSet(), + hasLength(3), + ); + final text = await compileWriterside(); + expect('Definition a retained.'.allMatches(text), hasLength(2)); + expect('Definition b retained.'.allMatches(text), hasLength(1)); + for (final (name, number) in [('a', 1), ('b', 2), ('a', 3)]) { + expect( + text, + matches( + RegExp('Reference $name\\s*$number\\s*and repeated $name\\s*$number'), + ), + ); + expect(text, matches(RegExp('$number\\s*Definition $name retained\\.'))); + } + }, skip: !pdfAvailable); + + final htmlService = HtmlExportService( + stylesheetLoader: () => File('assets/export/html.css').readAsString(), + ); + Future exportHtml(String source) => + htmlService.exportMarkdown( + MarkdownHtmlExportRequest( + source: source, + filePath: p.join(root.path, 'source.md'), + workspaceRoot: root.path, + destinationPath: p.join(root.path, 'out.html'), + options: const HtmlExportOptions( + content: ExportContentOptions(includeToc: true), + ), + ), + ); + + for (final xml in [false, true]) { + test( + 'Writerside diagram and paragraph collisions retain each occurrence (XML=$xml)', + () async { + final ext = xml ? 'topic' : 'md'; + await configure( + '', + ); + for (final name in ['one', 'two', 'three']) { + final body = name == 'three' + ? 'PARAGRAPH_SENTINEL' + : 'graph LR; $name-->end'; + await put( + 'Writerside/topics/$name.$ext', + xml + ? '${name == 'three' ? '

    $body

    ' : '$body'}
    ' + : '# Topic\n\n${name == 'three' ? body : '```mermaid\n$body\n```'}', + ); + } + final document = await compose(); + final ids = _blocks(document.blocks).map((b) => b.id).toList(); + expect(ids.toSet(), hasLength(ids.length)); + final renderer = _DiagramRenderer(); + final coordinator = VisualizationCoordinator( + renderers: [renderer], + cache: VisualizationCache( + diskRoot: Directory(p.join(root.path, 'cache')), + ), + ); + addTearDown(coordinator.dispose); + final prepared = + await MarkdownVisualizationExportRenderer( + coordinator: coordinator, + ).prepare( + document: document, + exportRoot: root, + documentPath: document.filePath, + workspaceRoot: module, + cancellationToken: MarkdownPdfCancellationToken(), + ); + expect(renderer.requests.map((r) => r.blockKey).toSet(), hasLength(2)); + expect( + renderer.requests.map((r) => r.source), + containsAll(['graph LR; one-->end', 'graph LR; two-->end']), + ); + expect(prepared.blockOverrides, hasLength(2)); + expect( + prepared.blockOverrides.values + .map((b) => b.attributes['asset']) + .toSet(), + hasLength(2), + ); + final mapped = const MarkdownExportMapper().map( + document, + blockOverrides: prepared.blockOverrides, + ); + expect( + mapped.blocks.where( + (b) => b.kind == MarkdownExportBlockKind.visualization, + ), + hasLength(2), + ); + expect( + mapped.blocks.expand((b) => b.inlines).map((i) => i.text), + contains('PARAGRAPH_SENTINEL'), + ); + }, + ); + } + test('repeated includes receive distinct nested occurrence IDs', () async { + await configure(''); + await put( + 'Writerside/topics/main.topic', + '', + ); + await put( + 'Writerside/topics/snippet.topic', + '
  • Repeated

  • ', + ); + final blocks = _blocks((await compose()).blocks).toList(); + expect(blocks.map((b) => b.id).toSet(), hasLength(blocks.length)); + expect( + blocks.where((b) => b.plainText == 'Repeated').length, + greaterThanOrEqualTo(2), + ); + }); + test('mapper cannot substitute a diagram for a colliding paragraph', () { + final result = const MarkdownExportMapper().map( + BusyDocument( + filePath: 'x', + mode: MarkdownMode.commonMark, + blocks: const [ + BusyBlock( + id: 'b0', + kind: BusyBlockKind.paragraph, + inlines: [BusyInline(kind: BusyInlineKind.text, text: 'Keep me')], + ), + ], + ), + blockOverrides: const { + 'b0': MarkdownExportBlock(kind: MarkdownExportBlockKind.visualization), + }, + ); + expect(result.blocks.single.kind, MarkdownExportBlockKind.paragraph); + expect(result.blocks.single.inlines.single.text, 'Keep me'); + }); + + test( + 'Writerside rejects outside file URIs and symlinks while staging shared-module images', + () async { + const outside = + 'PRIVATE_SENTINEL'; + const safe = + ''; + await put('private.svg', outside); + await put('Writerside/images/safe.svg', safe); + await Link( + p.join(module, 'images/escape.svg'), + ).create(p.join(root.path, 'private.svg')); + await put( + 'Shared/writerside.cfg', + '', + ); + await put('Shared/images/shared.svg', safe.replaceFirst('10', '20')); + await put( + 'Shared/topics/library.topic', + '', + ); + await configure(''); + await put( + 'Writerside/topics/main.topic', + '', + ); + final document = const MarkdownExportMapper().map(await compose()); + final stage = await Directory(p.join(root.path, 'stage')).create(); + final assets = await const MarkdownExportAssetStager().stage( + document: document, + exportRoot: stage, + activeFilePath: p.join(module, 'topics/main.topic'), + workspaceRoot: module, + cancellationToken: MarkdownPdfCancellationToken(), + ); + expect(assets.assets, hasLength(2)); + expect(assets.warnings, hasLength(2)); + for (final path in assets.assets.values) { + expect( + await File(p.join(stage.path, path)).readAsString(), + isNot(contains('PRIVATE_SENTINEL')), + ); + } + }, + ); + + for (final onlyReference in [false, true]) { + test( + 'cross-instance navigation is not published (reference only=$onlyReference)', + () async { + await configure( + '${onlyReference ? '' : ''}', + extra: '', + ); + await put( + 'Writerside/other.tree', + '', + ); + await put( + 'Writerside/topics/own.topic', + '

    OWN_CONTENT

    ', + ); + await put( + 'Writerside/topics/foreign.topic', + '

    FOREIGN_CONTENT

    ', + ); + if (onlyReference) { + await expectLater( + compose(), + throwsA( + isA().having( + (e) => e.detail, + 'detail', + contains('no exportable topics'), + ), + ), + ); + } else { + final text = _blocks( + (await compose()).blocks, + ).map((b) => b.plainText).join(' '); + expect(text, contains('OWN_CONTENT')); + expect(text, isNot(contains('FOREIGN_CONTENT'))); + } + }, + ); + } + + for (final first in [true, false]) { + for (final inline in [false, true]) { + test( + 'explicit ${inline ? 'inline' : 'raw'} anchor is reserved (first=$first)', + () async { + final raw = inline + ? 'Paragraph Explicit target.' + : '
    Explicit target
    '; + final result = await exportHtml( + '${first ? '$raw\n\n# Intro' : '# Intro\n\n$raw'}\n\n[Authored link](#intro)', + ); + final doc = html.parse( + await File(result.entryPointPath).readAsString(), + ); + expect( + doc.getElementById('intro')!.localName, + inline ? 'code' : 'div', + ); + final tocLink = doc.querySelector('.outline a')!; + final target = doc.getElementById( + tocLink.attributes['href']!.substring(1), + )!; + expect(target.localName, 'h1'); + expect(target.text, 'Intro'); + expect(doc.querySelector('article a')!.attributes['href'], '#intro'); + }, + ); + } + } + test('collapsible headings keep numbering and actual outline order', () async { + await configure(''); + await put( + 'Writerside/topics/main.topic', + '

    Before text

    Fold text

    After text

    ', + ); + final result = await htmlService.exportWriterside( + projectRoot: root.path, + moduleRoot: module, + instanceId: 'guide', + destinationPath: p.join(root.path, 'site'), + options: const HtmlExportOptions( + content: ExportContentOptions(includeToc: true, numberHeadings: true), + ), + ); + final doc = html.parse(await File(result.entryPointPath).readAsString()); + expect(doc.querySelectorAll('.outline a').map((a) => a.text), [ + '1 Main', + '1.1 Before', + '1.2 Fold', + '1.3 After', + ]); + expect(doc.getElementById('fold')!.localName, 'details'); + expect(doc.getElementById('fold')!.querySelector('h2')!.text, '1.2 Fold'); + expect(doc.querySelector('.outline a[href="#fold"]'), isNotNull); + }); + for (final raw in [false, true]) { + for (final destination in ['#%FF', '%FF.md', 'file%FF.txt']) { + test( + 'malformed encoded link becomes a source warning ($raw $destination)', + () async { + final result = await exportHtml( + '# Title\n\n${raw ? '

    Keep visible

    ' : '[Keep visible]($destination)'}', + ); + final doc = html.parse( + await File(result.entryPointPath).readAsString(), + ); + expect(doc.querySelector('article')!.text, contains('Keep visible')); + expect(doc.querySelectorAll('article a[href]'), isEmpty); + expect( + result.warnings, + contains( + isA() + .having((w) => w.code, 'code', 'link.unresolved') + .having( + (w) => w.sourcePath, + 'source', + p.join(root.path, 'source.md'), + ) + .having((w) => w.line, 'line', 3), + ), + ); + }, + ); + } + } + + test( + 'combined Writerside PDF keeps diagrams and scoped notes without reading an outside image', + () async { + await configure( + '', + ); + await put( + 'private.svg', + 'PRIVATE_SENTINEL', + ); + for (final name in ['one', 'two', 'three']) { + await put( + 'Writerside/topics/$name.md', + '# Topic $name\n\n${name == 'three' ? 'PARAGRAPH_SENTINEL' : '```mermaid\ngraph LR; $name-->end\n```'}\n\nReference[^note] and repeated[^note].\n\n[^note]: Definition for $name retained.\n\n${name == 'three' ? '![Private](${Uri.file(p.join(root.path, 'private.svg'))})' : ''}', + ); + } + final renderer = _DiagramRenderer(); + final coordinator = VisualizationCoordinator( + renderers: [renderer], + cache: VisualizationCache( + diskRoot: Directory(p.join(root.path, 'cache')), + ), + ); + addTearDown(coordinator.dispose); + final path = p.join(root.path, 'combined.pdf'); + final result = + await WritersidePdfExportService( + markdownExporter: MarkdownPdfExportService( + compilerLocator: TypstCompilerLocator( + environment: {'BUSYMARK_TYPST_PATH': compiler!}, + ), + templateLoader: () => + File('assets/export/markdown.typ').readAsString(), + visualizationRenderer: MarkdownVisualizationExportRenderer( + coordinator: coordinator, + ), + ), + ).export( + WritersidePdfExportRequest( + moduleRoot: module, + projectRoot: root.path, + instanceId: 'guide', + destinationPath: path, + overwrite: false, + ), + ); + final extracted = await Process.run('/usr/bin/pdftotext', [ + '-layout', + path, + '-', + ]); + expect(extracted.exitCode, 0, reason: '${extracted.stderr}'); + final text = extracted.stdout as String; + expect(text, contains('PARAGRAPH_SENTINEL')); + expect(text, isNot(contains('PRIVATE_SENTINEL'))); + expect( + renderer.requests.map((r) => r.source), + containsAll(['graph LR; one-->end', 'graph LR; two-->end']), + ); + for (final name in ['one', 'two', 'three']) { + expect('Definition for $name retained.'.allMatches(text), hasLength(1)); + } + expect( + result.warnings.map((w) => w.code), + contains(MarkdownPdfWarningCode.imageUnsupported), + ); + }, + skip: !pdfAvailable, + ); + + test( + 'ordinary Markdown footnotes compile with retained definitions and repeated references', + () async { + final path = p.join(root.path, 'footnotes.pdf'); + final result = + await MarkdownPdfExportService( + compilerLocator: TypstCompilerLocator( + environment: {'BUSYMARK_TYPST_PATH': compiler!}, + ), + templateLoader: () => + File('assets/export/markdown.typ').readAsString(), + ).export( + MarkdownPdfExportRequest( + source: + '# Notes\n\nFirst[^note], repeated[^note], and another[^other].\n\n[^note]: Complete **first definition** with `code`.\n\n Second paragraph retained.\n\n[^other]: Other definition retained.\n', + filePath: p.join(root.path, 'source.md'), + workspaceRoot: root.path, + destinationPath: path, + mode: MarkdownMode.gfm, + options: const PdfExportOptions(), + overwrite: false, + ), + ); + final extracted = await Process.run('/usr/bin/pdftotext', [ + '-layout', + path, + '-', + ]); + expect(extracted.exitCode, 0, reason: '${extracted.stderr}'); + final text = extracted.stdout as String; + for (final value in [ + 'Complete first definition with code.', + 'Second paragraph retained.', + 'Other definition retained.', + ]) { + expect(text, contains(value)); + } + expect('Complete first definition'.allMatches(text), hasLength(1)); + expect( + text, + matches(RegExp(r'First\s*1\s*,\s*repeated\s*1\s*,\s*and another\s*2')), + ); + expect(result.pageCount, isNull); + }, + skip: !pdfAvailable, + ); +} + +Iterable _blocks(Iterable values) sync* { + for (final b in values) { + yield b; + yield* _blocks(b.children); + } +} + +class _RecordingExporter extends MarkdownPdfExportService { + BusyDocument? document; + @override + Future export( + MarkdownPdfExportRequest request, { + MarkdownPdfCancellationToken? cancellationToken, + }) async { + document = request.document; + return MarkdownPdfExportResult( + destinationPath: request.destinationPath, + pageCount: null, + warnings: const [], + ); + } +} + +class _DiagramRenderer implements VisualizationRenderer { + final requests = []; + @override + Set get supportedKinds => { + VisualizationRendererKind.mermaid, + }; + @override + Future prepare( + VisualizationRenderRequest request, + VisualizationCancellationToken token, + ) async => request; + @override + Future render( + VisualizationRenderRequest request, + VisualizationCancellationToken token, + ) async { + requests.add(request); + await Future.delayed(const Duration(milliseconds: 10)); + return SvgVisualizationResult( + svg: + '', + width: 20, + height: 10, + ); + } +} + +Iterable _exportBlocks( + Iterable values, +) sync* { + for (final block in values) { + yield block; + yield* _exportBlocks(block.children); + } +} diff --git a/test/src/header_bar_configuration_test.dart b/test/src/header_bar_configuration_test.dart index f01dcd93..bed0a97d 100644 --- a/test/src/header_bar_configuration_test.dart +++ b/test/src/header_bar_configuration_test.dart @@ -94,6 +94,8 @@ void main() { 'searchQuery': '', 'textDirection': 'ltr', 'canRefresh': false, + 'canExportPdf': false, + 'canExportHtml': false, 'documentControlsVisible': false, 'searchActive': false, 'searchVisible': false, @@ -437,11 +439,14 @@ void main() { _labels.toMap(), containsPair('syntaxReferenceGtkAccelerator', 'F1'), ); + expect(_labels.toMap(), containsPair('export', 'Export')); + expect(_labels.toMap(), containsPair('exportShortcut', 'Ctrl+Shift+E')); + expect( + _labels.toMap(), + containsPair('exportGtkAccelerator', 'e'), + ); expect(_labels.toMap(), isNot(contains('markdownAndHtml'))); for (final removedLabel in { - 'export', - 'exportShortcut', - 'exportGtkAccelerator', 'clipboardHistory', 'localHistory', 'findLocalHistory', @@ -503,6 +508,9 @@ const _labels = HeaderBarLabels( back: 'Back', backShortcut: 'Alt+Left', save: 'Save', + export: 'Export', + exportShortcut: 'Ctrl+Shift+E', + exportGtkAccelerator: 'e', fullScreen: 'Full Screen', fullScreenShortcut: 'F11', fullScreenGtkAccelerator: 'F11', diff --git a/test/src/html_export_options_test.dart b/test/src/html_export_options_test.dart index a4a32a40..644550be 100644 --- a/test/src/html_export_options_test.dart +++ b/test/src/html_export_options_test.dart @@ -3,10 +3,13 @@ import 'dart:io'; import 'package:busymark/src/export/html_export_models.dart'; import 'package:busymark/src/export/html_export_service.dart'; import 'package:busymark/src/export/html_export_styles.dart'; +import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:html/parser.dart' as html; import 'package:path/path.dart' as p; +import '../support/headless_chrome.dart'; + void main() { late Directory root; final service = HtmlExportService( @@ -156,6 +159,83 @@ void main() { }, ); + for (final (name, ending) in [('LF', '\n'), ('CRLF', '\r\n'), ('CR', '\r')]) { + Future styledExport() async { + final css = File(p.join(root.path, 'custom.css')); + await css.writeAsString( + ':root {$ending font-size: 23px;$ending}$ending', + ); + final result = + await HtmlExportService( + stylesheetLoader: () async => (await File( + 'assets/export/html.css', + ).readAsString()).replaceAll('\n', ending), + ).exportMarkdown( + MarkdownHtmlExportRequest( + source: '# Styled\n\nBody', + filePath: p.join(root.path, 'source.md'), + workspaceRoot: root.path, + destinationPath: p.join(root.path, 'output.html'), + options: HtmlExportOptions(customCssPath: css.path), + ), + ); + return result.entryPointPath; + } + + test('$name CSS hashes the text seen by the HTML parser', () async { + final path = await styledExport(); + final output = await File(path).readAsString(); + final doc = html.parse(output); + final css = doc.querySelector('style')!.text; + expect(output, isNot(contains('\r'))); + expect(css, contains('font-size: 23px;')); + final hash = base64.encode(sha256.convert(utf8.encode(css)).bytes); + expect( + doc.querySelector('meta[http-equiv]')!.attributes['content'], + contains("style-src 'sha256-$hash'"), + ); + }); + + test('$name custom and built-in CSS apply in Chromium', () async { + final path = await styledExport(); + final styles = await evaluateInChrome(path, '''({ + font: getComputedStyle(document.documentElement).fontSize, + bodyMargin: getComputedStyle(document.body).marginTop + })'''); + expect(styles, {'font': '23px', 'bodyMargin': '0px'}); + }, skip: headlessChromePath == null ? 'Chromium is not installed.' : false); + } + + test( + 'single-file attachments retain download names and reject source data URLs', + () async { + await File(p.join(root.path, 'notes file.txt')).writeAsString('Notes'); + const dataUrl = 'data:text/plain;base64,Tm90ZXM='; + final result = await export( + const HtmlExportOptions(packaging: HtmlPackaging.singleFile), + source: + '# Downloads\n\n[Notes](notes%20file.txt)\n\n' + 'Raw notes\n\n' + '[Unsafe]($dataUrl)\n\n' + 'Unsafe raw', + ); + final doc = html.parse(await File(result.entryPointPath).readAsString()); + final downloads = doc.querySelectorAll('article a[download]'); + expect(downloads.map((e) => e.text), ['Notes', 'Raw notes']); + expect( + downloads.map((e) => e.attributes['download']), + everyElement('notes file.txt'), + ); + expect(downloads.map((e) => e.attributes['href']), everyElement(dataUrl)); + expect(doc.querySelectorAll('article a[href]'), hasLength(2)); + expect( + result.warnings.where((w) => w.code == 'link.unresolved'), + isNotEmpty, + ); + expect(result.assetsPath, isNull); + }, + ); + test( 'CSS rejects unsafe URLs, imports, HTML, unreadable and oversized files', () async { diff --git a/test/src/html_export_ui_test.dart b/test/src/html_export_ui_test.dart index 2d6d3967..b5392c2f 100644 --- a/test/src/html_export_ui_test.dart +++ b/test/src/html_export_ui_test.dart @@ -34,27 +34,40 @@ void main() { expect(await registry.execute(BusyMarkCommandIds.export), isTrue); expect(calls, 1); final native = File('linux/runner/my_application.cc').readAsStringSync(); - expect(native, isNot(contains('"header.export"'))); + expect(native, contains('"header.export"')); expect(native, isNot(contains('"header.export-html"'))); expect(native, isNot(contains('"header.export-pdf"'))); - expect(native, isNot(contains('configuration.can_export_html'))); - expect(native, isNot(contains('"setCanExportHtml"'))); + expect(native, contains('configuration.can_export_pdf')); + expect(native, contains('configuration.can_export_html')); + expect(native, contains('"setCanExportPdf"')); + expect(native, contains('"setCanExportHtml"')); }); - testWidgets('Flutter Main menu excludes document export', (tester) async { + testWidgets('Flutter Main menu exposes document export', (tester) async { + var selected = false; await tester.pumpWidget( ProviderScope( child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold(body: BusyMarkMainMenuButton(onSelected: (_) {})), + home: Scaffold( + body: BusyMarkMainMenuButton( + canExport: true, + onSelected: (action) => + selected = action == BusyMarkMainMenuAction.export, + ), + ), ), ), ); await tester.tap(find.byType(BusyMarkMainMenuButton)); await tester.pumpAndSettle(); - expect(find.text('Export'), findsNothing); + expect(find.text('Export'), findsOneWidget); + expect(find.text('Ctrl+Shift+E'), findsOneWidget); expect(find.text('Full Screen'), findsOneWidget); + await tester.tap(find.text('Export')); + await tester.pumpAndSettle(); + expect(selected, isTrue); }); testWidgets( @@ -159,23 +172,15 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Unsaved changes'), findsOneWidget); await tester.tap(find.text(action)); - for ( - var i = 0; - i < 60 && find.text('Instance').evaluate().isEmpty; - i++ - ) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 50)), - ); - await tester.pump(const Duration(milliseconds: 100)); - } if (action == 'Cancel') { + await tester.pumpAndSettle(); expect(find.text('Instance'), findsNothing); expect( ref.read(workspaceControllerProvider).hasUnsavedChanges, isTrue, ); } else { + await _pumpUntilFound(tester, find.text('Instance')); expect(find.text('Instance'), findsOneWidget); expect( File(p.join(root.path, 'topics/a.topic')).readAsStringSync(), @@ -192,6 +197,17 @@ void main() { } } +Future _pumpUntilFound(WidgetTester tester, Finder finder) async { + for (var attempt = 0; attempt < 240; attempt++) { + if (finder.evaluate().isNotEmpty) return; + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + await tester.pump(const Duration(milliseconds: 100)); + } + fail('Timed out waiting for $finder.'); +} + Widget _harness(HtmlExportService service, void Function(WidgetRef) ready) => ProviderScope( overrides: [ diff --git a/test/src/html_publication_test.dart b/test/src/html_publication_test.dart index 9c3e269b..8c6ee2f1 100644 --- a/test/src/html_publication_test.dart +++ b/test/src/html_publication_test.dart @@ -49,6 +49,123 @@ void main() { overwrite: overwrite, options: options, ); + test('single-file local video has a downloadable fallback', () async { + await module( + 'module', + 'module', + '', + ); + await put( + 'module/topics/Start.topic', + '', + ); + await put( + 'module/images/clip.mp4', + '\u0000\u0000\u0000\u0018ftypmp42\u0000\u0000\u0000\u0000mp42', + ); + final result = await site( + options: const HtmlExportOptions(packaging: HtmlPackaging.singleFile), + ); + final doc = html.parse(await File(result.entryPointPath).readAsString()); + final video = doc.querySelector('video')!; + final fallback = video.querySelector('a')!; + expect(video.attributes['src'], startsWith('data:video/mp4;base64,')); + expect(fallback.attributes['href'], video.attributes['src']); + expect(fallback.attributes['download'], 'clip.mp4'); + expect(result.warnings, isEmpty); + }); + + test( + 'Writerside list starts and same-type boundaries survive HTML export', + () async { + await module( + 'module', + 'module', + '', + ); + await put( + 'module/topics/Start.topic', + await File( + 'test/fixtures/writerside/list_numbering.topic', + ).readAsString(), + ); + final result = await site(); + final doc = html.parse(await File(result.entryPointPath).readAsString()); + final lists = doc.querySelectorAll('article > ol'); + expect(lists, hasLength(3)); + expect(lists[0].attributes['type'], 'a'); + expect(lists[0].attributes['start'], '3'); + expect(lists.map((list) => list.children.map((li) => li.text).toList()), [ + ['Charlie', 'Delta'], + ['First one', 'First two'], + ['Second one', 'Second two'], + ]); + for (final list in lists.skip(1)) { + expect(list.attributes['start'] ?? '1', '1'); + } + final steps = doc.querySelectorAll('.procedure > ol'); + expect(steps, hasLength(1)); + expect(steps.single.children.map((li) => li.text), [ + 'Step one', + 'Step two', + ]); + expect(result.warnings, isEmpty); + }, + ); + + test('repeated list includes export as separate HTML containers', () async { + final fixture = Directory( + 'test/fixtures/writerside/repeated_list_include', + ).absolute; + final result = await const HtmlExportService().exportWriterside( + projectRoot: fixture.path, + moduleRoot: fixture.path, + instanceId: 'guide', + destinationPath: p.join(root.path, 'site'), + ); + final doc = html.parse(await File(result.entryPointPath).readAsString()); + final lists = doc.querySelectorAll('article > ol'); + expect(lists, hasLength(2)); + for (final list in lists) { + expect(list.children.map((item) => item.text), [ + 'Included first', + 'Included second', + ]); + expect(list.attributes['start'] ?? '1', '1'); + } + expect(result.warnings, isEmpty); + }); + + test('Writerside list styles stay distinct across consecutive lists', () async { + await module( + 'module', + 'module', + '', + ); + await put('module/topics/Start.topic', ''' +
  • Alpha one
  • Alpha two
  • +
  • Decimal
  • +
  • Unmarked
  • +
  • Bullet
  • +
  • Checked
  • +
    '''); + final result = await site(); + final doc = html.parse(await File(result.entryPointPath).readAsString()); + final lists = doc.querySelectorAll('article > ol, article > ul'); + expect(lists.map((e) => e.localName), ['ol', 'ol', 'ul', 'ul', 'ul']); + expect(lists.first.attributes['type'], 'a'); + expect(lists.first.children, hasLength(2)); + expect(lists[1].attributes['start'], '3'); + expect(lists[1].attributes['type'], isNull); + final hiddenClass = lists[2].classes.single; + expect( + doc.querySelector('style')!.text, + contains('.$hiddenClass{list-style-type:none;}'), + ); + expect(lists[3].classes, isEmpty); + expect(lists[4].querySelector('input[checked]'), isNotNull); + }); + test( 'Writerside shares HTML appearance without changing TOC status or source', () async { diff --git a/test/src/html_rich_export_test.dart b/test/src/html_rich_export_test.dart index 13b003e8..7e0176f2 100644 --- a/test/src/html_rich_export_test.dart +++ b/test/src/html_rich_export_test.dart @@ -16,6 +16,49 @@ import 'package:html/parser.dart' as html; import 'package:path/path.dart' as p; void main() { + for (final language in ['mermaid', 'openapi']) { + test('rendered $language retains a linked Writerside block ID', () async { + final root = await Directory.systemTemp.createTemp( + 'html-diagram-anchor-', + ); + addTearDown(() => root.delete(recursive: true)); + await Directory(p.join(root.path, 'topics')).create(); + await File(p.join(root.path, 'writerside.cfg')).writeAsString( + '', + ); + await File(p.join(root.path, 'guide.tree')).writeAsString( + '', + ); + await File(p.join(root.path, 'topics/Start.topic')).writeAsString( + '' + '${language == 'mermaid' ? 'graph LR; A-->B' : 'openapi: 3.1.0'}' + '

    See diagram

    ', + ); + final visualization = VisualizationCoordinator( + renderers: [_Diagrams()], + cache: VisualizationCache( + diskRoot: Directory(p.join(root.path, 'cache')), + ), + ); + addTearDown(visualization.dispose); + final result = await HtmlExportService(visualization: visualization) + .exportWriterside( + projectRoot: root.path, + moduleRoot: root.path, + instanceId: 'guide', + destinationPath: p.join(root.path, 'out'), + ); + final doc = html.parse(await File(result.entryPointPath).readAsString()); + expect( + doc.getElementById('flow')!.localName, + language == 'mermaid' ? 'figure' : 'section', + ); + expect(doc.querySelectorAll('[id="flow"]'), hasLength(1)); + expect(doc.querySelector('a[href="#flow"]')!.text, 'See diagram'); + expect(doc.querySelector('pre'), isNull); + expect(result.warnings, isEmpty); + }); + } TestWidgetsFlutterBinding.ensureInitialized(); test( 'HTML uses unique occurrence jobs, standalone math and diagram/static API engines', diff --git a/test/src/linux_header_bar_service_test.dart b/test/src/linux_header_bar_service_test.dart index 6ae8e190..387c2678 100644 --- a/test/src/linux_header_bar_service_test.dart +++ b/test/src/linux_header_bar_service_test.dart @@ -100,6 +100,7 @@ void main() { await sendNativeAction('save'); await sendNativeAction('save'); + await sendNativeAction('export'); await sendNativeAction('fullScreen'); await sendNativeAction('reportIssue'); await sendNativeAction('sidebarFiles'); @@ -112,6 +113,7 @@ void main() { expect(events.map((event) => event.action), [ HeaderBarAction.save, HeaderBarAction.save, + HeaderBarAction.export, HeaderBarAction.fullScreen, HeaderBarAction.reportIssue, HeaderBarAction.sidebarFiles, @@ -132,6 +134,7 @@ void main() { 8, 9, 10, + 11, ]); expect(events.first, isNot(events.last)); }); diff --git a/test/src/localization_audit_test.dart b/test/src/localization_audit_test.dart index 008f9d14..46168b8b 100644 --- a/test/src/localization_audit_test.dart +++ b/test/src/localization_audit_test.dart @@ -1258,6 +1258,9 @@ const _nativeGtkUserFacingPatterns = <_LiteralPattern>[ ]; const _sharedEnglishMatches = { + // Standard field labels also used verbatim in French and German. + 'pdfTitlePageVersion', + 'pdfTitlePageDate', 'appTitle', 'aboutLicenseName', 'markdown', diff --git a/test/src/markdown_export_mapper_test.dart b/test/src/markdown_export_mapper_test.dart index a06cc718..def2342d 100644 --- a/test/src/markdown_export_mapper_test.dart +++ b/test/src/markdown_export_mapper_test.dart @@ -1,16 +1,98 @@ import 'dart:convert'; +import 'dart:io'; import 'package:busymark/src/export/markdown_export_document.dart'; import 'package:busymark/src/export/markdown_export_mapper.dart'; import 'package:busymark/src/export/markdown_pdf_models.dart'; import 'package:busymark/src/export/typst_payload_builder.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/writerside/writerside_document_parser.dart'; +import 'package:busymark/src/writerside/writerside_document_renderer.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { const parser = MarkdownParser(); const mapper = MarkdownExportMapper(); + test('Writerside numeric list starts and container boundaries reach PDF', () { + final parsed = const WritersideDocumentParser().parseXml( + filePath: '/workspace/lists.topic', + source: File( + 'test/fixtures/writerside/list_numbering.topic', + ).readAsStringSync(), + ); + final document = mapper.map( + const WritersideDocumentRenderer().toBusyDocument(parsed), + ); + final lists = document.blocks + .where((b) => b.kind == MarkdownExportBlockKind.list) + .toList(); + expect(lists, hasLength(3)); + expect(lists.map((b) => b.attributes['start']), [3, 1, 1]); + expect(lists.map((b) => b.attributes['listType']), [ + 'alpha-lower', + 'decimal', + 'decimal', + ]); + expect(lists.map((b) => b.children.length), [2, 2, 2]); + final procedure = document.blocks.last; + expect(procedure.kind, MarkdownExportBlockKind.group); + expect(procedure.children, hasLength(1)); + expect(procedure.children.single.kind, MarkdownExportBlockKind.list); + expect(procedure.children.single.attributes['start'], 1); + expect(procedure.children.single.children, hasLength(2)); + }); + + test('list items and visualization replacements retain source anchors', () { + const source = BusyDocument( + filePath: '/workspace/guide.md', + mode: MarkdownMode.gfm, + blocks: [ + BusyBlock( + id: 'task', + kind: BusyBlockKind.taskListItem, + attributes: {'task': 'true', 'pdf-anchor': 'task-anchor'}, + ), + BusyBlock( + id: 'diagram', + kind: BusyBlockKind.codeBlock, + attributes: {'language': 'mermaid', 'pdf-anchor': 'diagram-anchor'}, + ), + ], + ); + for (final replace in [false, true]) { + final result = mapper.map( + source, + blockOverrides: { + if (replace) + 'diagram': const MarkdownExportBlock( + kind: MarkdownExportBlockKind.visualization, + attributes: {'asset': 'generated-assets/diagram.svg'}, + ), + }, + ); + expect(result.blocks.first.children.single.attributes, { + 'task': true, + 'anchor': 'task-anchor', + }); + expect(result.blocks.last.attributes['anchor'], 'diagram-anchor'); + expect( + result.blocks.last.kind, + replace + ? MarkdownExportBlockKind.visualization + : MarkdownExportBlockKind.code, + ); + if (replace) { + expect( + result.blocks.last.attributes['asset'], + 'generated-assets/diagram.svg', + ); + } + } + }); + test('maps Markdown semantics into renderer-neutral export blocks', () { final parsed = parser.parse( filePath: '/workspace/guide.md', diff --git a/test/src/markdown_math_export_test.dart b/test/src/markdown_math_export_test.dart index e96af596..ca70797f 100644 --- a/test/src/markdown_math_export_test.dart +++ b/test/src/markdown_math_export_test.dart @@ -9,6 +9,7 @@ import 'package:busymark/src/export/markdown_pdf_models.dart'; import 'package:busymark/src/export/typst_compiler.dart'; import 'package:busymark/src/export/typst_payload_builder.dart'; import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/markdown/raw_html_adapter.dart'; import 'package:busymark/src/math/math_coordinator.dart'; import 'package:busymark/src/math/math_renderer.dart'; import 'package:busymark/src/visualization/visualization_renderer.dart'; @@ -186,8 +187,114 @@ $$ }, ); + test( + 'footnote inline and display math retain surrounding semantics', + () async { + final mapped = const MarkdownExportMapper().map( + const MarkdownParser() + .parse(filePath: '/footnotes.md', source: _mathFootnoteSource) + .busyDocument, + ); + final blocks = _allBlocks(mapped.blocks).toList(); + final inlines = blocks.expand((b) => _allInlines(b.inlines)).toList(); + expect( + inlines + .where((i) => i.kind == MarkdownExportInlineKind.math) + .map((i) => i.text), + [r'x^2'], + ); + expect( + blocks + .where((b) => b.kind == MarkdownExportBlockKind.math) + .map((b) => b.text), + [r'\frac{a}{b}'], + ); + expect( + inlines + .where((i) => i.kind == MarkdownExportInlineKind.strong) + .map((i) => i.text), + contains('Bold retained'), + ); + expect( + inlines + .where((i) => i.kind == MarkdownExportInlineKind.emphasis) + .map((i) => i.text), + contains('Emphasis retained'), + ); + expect( + inlines + .where((i) => i.kind == MarkdownExportInlineKind.link) + .map((i) => i.destination), + contains('https://example.com'), + ); + expect(mapped.imageDestinations, contains('safe.svg')); + // Internal parser math tags remain forbidden in authored raw HTML. + expect( + const RawHtmlAdapter() + .parseRawHtmlBlock( + '

    x

    ', + () => 'raw', + )! + .safe, + isFalse, + ); + }, + ); + final typstPath = Platform.environment['BUSYMARK_TYPST_PATH']; final canRunTypst = typstPath != null && File(typstPath).existsSync(); + test('Typst compiles footnote math through the math renderer', () async { + final root = await Directory.systemTemp.createTemp( + 'busymark-footnote-math-', + ); + addTearDown(() => root.delete(recursive: true)); + await File(p.join(root.path, 'safe.svg')).writeAsString( + '', + ); + final host = _PdfMathHost(); + final coordinator = MathCoordinator(renderer: MathRenderer(host: host)); + addTearDown(coordinator.dispose); + final destination = p.join(root.path, 'footnote.pdf'); + final result = + await MarkdownPdfExportService( + compilerLocator: TypstCompilerLocator( + environment: {'BUSYMARK_TYPST_PATH': typstPath!}, + ), + mathRenderer: MarkdownMathExportRenderer(coordinator: coordinator), + templateLoader: () => + File('assets/export/markdown.typ').readAsString(), + ).export( + MarkdownPdfExportRequest( + source: _mathFootnoteSource, + filePath: p.join(root.path, 'notes.md'), + workspaceRoot: root.path, + destinationPath: destination, + options: const PdfExportOptions(), + overwrite: false, + ), + ); + final requests = host.batches.expand((b) => b).toList(); + expect(requests.map((r) => r['expression']), [r'x^2', r'\frac{a}{b}']); + expect(requests.map((r) => r['display']), [false, true]); + expect(result.warnings, isEmpty); + final extracted = await Process.run('/usr/bin/pdftotext', [ + '-layout', + destination, + '-', + ]); + expect(extracted.exitCode, 0, reason: '${extracted.stderr}'); + final text = extracted.stdout as String; + for (final value in [ + 'Bold retained', + 'Emphasis retained', + 'Linked text', + 'After equation retained', + ]) { + expect(text, contains(value)); + } + expect(text, isNot(contains(r'\frac{a}{b}'))); + }, skip: !canRunTypst || !File('/usr/bin/pdftotext').existsSync()); + test( 'Typst keeps MathJax SVG inline and exports failed math visibly', () async { @@ -273,3 +380,36 @@ class _PdfMathHost implements WebRenderHost { @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } + +const _mathFootnoteSource = r'''# Notes + +Reference[^math] and repeated[^math]. + +[^math]: **Bold retained** before $x^2$ and *Emphasis retained*. + + [Linked text](https://example.com) and ![Image retained](safe.svg). + + $$ + \frac{a}{b} + $$ + + After equation retained. +'''; + +Iterable _allBlocks( + Iterable values, +) sync* { + for (final block in values) { + yield block; + yield* _allBlocks(block.children); + } +} + +Iterable _allInlines( + Iterable values, +) sync* { + for (final inline in values) { + yield inline; + yield* _allInlines(inline.children); + } +} diff --git a/test/src/markdown_pdf_export_service_test.dart b/test/src/markdown_pdf_export_service_test.dart index d91e237e..bae8486c 100644 --- a/test/src/markdown_pdf_export_service_test.dart +++ b/test/src/markdown_pdf_export_service_test.dart @@ -336,7 +336,7 @@ void main() => print("Hello"); expect(bytes.take(5), [0x25, 0x50, 0x44, 0x46, 0x2d]); expect(bytes.length, greaterThan(1000)); expect(result.destinationPath, p.normalize(p.absolute(destination))); - expect(result.pageCount, anyOf(isNull, greaterThanOrEqualTo(1))); + expect(result.pageCount, isNull); expect( result.warnings.map((warning) => warning.code), contains(MarkdownPdfWarningCode.remoteImageSkipped), @@ -448,6 +448,7 @@ class _WritersideMarkdownParser extends MarkdownParser { MarkdownMode mode = MarkdownMode.commonMark, String? workspaceRoot, bool validateLocalReferences = true, + Map sourceOverrides = const {}, }) { return super.parseAsync( filePath: filePath, @@ -455,6 +456,7 @@ class _WritersideMarkdownParser extends MarkdownParser { mode: MarkdownMode.writersideMarkdown, workspaceRoot: workspaceRoot, validateLocalReferences: validateLocalReferences, + sourceOverrides: sourceOverrides, ); } } diff --git a/test/src/markdown_toc_generator_test.dart b/test/src/markdown_toc_generator_test.dart index e686fad4..ddd12c38 100644 --- a/test/src/markdown_toc_generator_test.dart +++ b/test/src/markdown_toc_generator_test.dart @@ -140,5 +140,28 @@ title: Product guide DiagnosticSeverity.hint, ); expect(byCode, contains('markdown.table.empty-header')); + expect(byCode['markdown.table.empty-header']?.sourceSpan?.startLine, 9); + }); + + test('does not report populated table headers as empty', () { + final parsed = const MarkdownParser().parse( + filePath: 'table.md', + source: '''# Formatting + +| Formatting | Markdown | Result | +| --- | --- | --- | +| Bold text | `**text**` | **text** | +| Italic text | `*text*` | *text* | +''', + mode: MarkdownMode.gfm, + validateLocalReferences: false, + ); + + expect( + parsed.diagnostics.where( + (diagnostic) => diagnostic.code == 'markdown.table.empty-header', + ), + isEmpty, + ); }); } diff --git a/test/src/native_headerbar_audit_test.dart b/test/src/native_headerbar_audit_test.dart index 8acf282c..27369bdf 100644 --- a/test/src/native_headerbar_audit_test.dart +++ b/test/src/native_headerbar_audit_test.dart @@ -90,6 +90,8 @@ void main() { 'title', 'viewMode', 'canRefresh', + 'canExportPdf', + 'canExportHtml', 'documentControlsVisible', 'searchActive', 'searchVisible', @@ -214,7 +216,9 @@ void main() { expect(service, contains('syntaxReference')); expect(service, contains('reportIssue')); expect(service, contains('fullScreen')); + expect(service, contains("'export' => HeaderBarAction.export")); expect(app, contains('menu: l10n.mainMenu')); + expect(app, contains('export: label(BusyMarkCommandIds.export)')); expect(app, contains('settings: label(BusyMarkCommandIds.settings)')); expect( app, @@ -244,6 +248,7 @@ void main() { expect(workspace, contains('case HeaderBarAction.keyboardShortcuts:')); expect(workspace, contains('case HeaderBarAction.syntaxReference:')); expect(workspace, contains('case HeaderBarAction.reportIssue:')); + expect(workspace, contains('case HeaderBarAction.export:')); expect(settings, contains('case HeaderBarAction.keyboardShortcuts:')); expect(settings, contains('case HeaderBarAction.syntaxReference:')); expect(settings, contains('case HeaderBarAction.reportIssue:')); @@ -259,8 +264,12 @@ void main() { mainMenu, contains('label: command(BusyMarkCommandIds.fullScreen).label(context)'), ); + expect( + mainMenu, + contains('label: command(BusyMarkCommandIds.export).label(context)'), + ); + expect(mainMenu, contains('enabled: canExport')); for (final removed in [ - 'BusyMarkCommandIds.export', 'generateOrUpdateMarkdownToc', 'BusyMarkCommandIds.clipboardHistory', 'BusyMarkCommandIds.localHistory', @@ -273,15 +282,16 @@ void main() { expect(native, contains('GSimpleActionGroup* header_action_group;')); expect(native, contains('rebuild_main_menu_model')); expect(native, isNot(contains('g_menu_item_new_submenu'))); - expect(native, isNot(contains('localized_label_or(labels, "export", "")'))); + expect(native, contains('localized_label_or(labels, "export", "")')); expect(native, contains('"header.keyboard-shortcuts"')); - expect(native, isNot(contains('"header.export"'))); + expect(native, contains('"header.export"')); expect(native, isNot(contains('"header.export-pdf"'))); expect(native, isNot(contains('"header.export-html"'))); expect(native, contains('"header.full-screen"')); expect(native, contains('full_screen_gaction_activated_cb')); expect(native, contains('configuration.full_screen')); - expect(native, isNot(contains('configuration.can_export_pdf'))); + expect(native, contains('configuration.can_export_pdf')); + expect(native, contains('configuration.can_export_html')); expect(native, isNot(contains('"header.clipboard-history"'))); expect(native, isNot(contains('"header.local-history"'))); expect(native, isNot(contains('"header.find-local-history"'))); diff --git a/test/src/pdf_export_options_render_test.dart b/test/src/pdf_export_options_render_test.dart index ca3b1880..d7c84c3a 100644 --- a/test/src/pdf_export_options_render_test.dart +++ b/test/src/pdf_export_options_render_test.dart @@ -53,6 +53,20 @@ void main() { content: ExportContentOptions(numberHeadings: true), footer: PdfRunningText.documentTitle, ), + for (final position in [ + PdfPageNumberPosition.bottomLeft, + PdfPageNumberPosition.bottomCenter, + PdfPageNumberPosition.bottomRight, + ]) + 'narrow-title-${position.name}': PdfExportOptions( + pageSize: PdfPageSize.letter, + margin: PdfMarginPreset.narrow, + header: PdfRunningText.documentTitle, + footer: PdfRunningText.documentTitle, + pageNumbers: position, + showHeaderFooterOnFirstPage: false, + content: const ExportContentOptions(includeToc: true), + ), }; for (final profile in profiles.entries) { test( @@ -194,6 +208,11 @@ ${List.filled(140, 'A paragraph of readable content to exercise later pages.\n') (pageIndex > 0 || o.showHeaderFooterOnFirstPage); expect(counters, hasLength(visible ? 1 : 0)); if (visible) { + expect( + double.parse(counters.single.getAttribute('yMax')!), + lessThanOrEqualTo(o.geometry.heightPt - 2), + reason: 'The complete page number must remain inside the page.', + ); final left = double.parse(counters.single.getAttribute('xMin')!); final right = double.parse(counters.single.getAttribute('xMax')!); final contentLeft = @@ -231,11 +250,19 @@ ${List.filled(140, 'A paragraph of readable content to exercise later pages.\n') if (profile.value.content.includeToc) { expect( pages.first.split('Main').skip(1).join('Main'), - matches(r'Child[^\n]*1'), + matches(r'Child[^\n]*2'), reason: 'Native outline retains destination page numbers even with hidden first-page footers.', ); } + final bodyPage = pages.indexWhere( + (page) => page.contains('A paragraph of readable content'), + ); + expect(bodyPage, profile.value.content.includeToc ? 1 : 0); + if (profile.value.content.includeToc) { + expect(pages.first, isNot(contains('void main()'))); + expect(pages.first, isNot(contains('A paragraph'))); + } expect(text.stdout, contains('void main()')); if (profile.value.content.numberHeadings) { expect(text.stdout, matches(r'1\.1\s+Child')); diff --git a/test/src/pdf_front_matter_render_test.dart b/test/src/pdf_front_matter_render_test.dart new file mode 100644 index 00000000..7ec46c8a --- /dev/null +++ b/test/src/pdf_front_matter_render_test.dart @@ -0,0 +1,736 @@ +import 'dart:io'; + +import 'package:busymark/src/export/markdown_pdf_export_service.dart'; +import 'package:busymark/src/export/markdown_pdf_models.dart'; +import 'package:busymark/src/export/typst_compiler.dart'; +import 'package:busymark/src/export/writerside_pdf_export_service.dart'; +import 'package:busymark/src/export/writerside_pdf_models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:xml/xml.dart'; + +const _source = '''--- +title: RUNNING_TEXT_SENTINEL +author: Source author +lang: en +--- +# Original heading + +BODY_START_SENTINEL is only in the document body. + +## Nested heading + +NESTED_BODY_SENTINEL stays linked. + +### Deep heading + +DEEP_BODY_SENTINEL stays present. +'''; +const _cover = PdfTitlePageData(title: 'COVER_ONLY_SENTINEL'); +const _chineseTitle = '中文技术文档编辑发布指南帮助团队编写清晰完整可靠的产品使用说明和参考资料'; +const _cjkFont = '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc'; + +void main() { + final compiler = Platform.environment['BUSYMARK_TYPST_PATH']; + final available = + compiler != null && + File(compiler).existsSync() && + File('/usr/bin/pdftotext').existsSync() && + File('/usr/bin/pdftohtml').existsSync(); + final skip = available + ? false + : 'Requires BUSYMARK_TYPST_PATH and Poppler tools.'; + late Directory root; + late MarkdownPdfExportService service; + setUp(() async { + root = await Directory.systemTemp.createTemp('pdf-front-matter-'); + service = MarkdownPdfExportService( + compilerLocator: TypstCompilerLocator( + environment: {'BUSYMARK_TYPST_PATH': compiler ?? '/unavailable'}, + ), + templateLoader: () => File('assets/export/markdown.typ').readAsString(), + ); + }); + tearDown(() async { + // Optional retained output is useful for manual visual inspection. + final artifacts = Platform.environment['BUSYMARK_PDF_ARTIFACTS']; + if (artifacts != null) { + await Directory(artifacts).create(recursive: true); + for (final file in root.listSync().whereType()) { + if (p.extension(file.path) == '.pdf') { + await file.copy(p.join(artifacts, p.basename(file.path))); + } + } + } + await root.delete(recursive: true); + }); + + Future export( + String name, + PdfExportOptions options, { + String source = _source, + PdfTitlePageData? cover = _cover, + }) async { + final path = p.join(root.path, '$name.pdf'); + final result = await service.export( + MarkdownPdfExportRequest( + source: source, + filePath: p.join(root.path, 'source.md'), + workspaceRoot: root.path, + destinationPath: path, + options: options, + titlePage: cover, + overwrite: false, + ), + ); + expect(result.warnings, isEmpty); + return path; + } + + Future useCjkFont() async { + // Keep the normal bundled fonts and add a real CJK fallback for these + // layout regressions. Missing-glyph boxes cannot verify text retention. + final bundle = Directory(p.join(root.path, 'cjk-bundle')); + final executable = p.join(bundle.path, 'libexec', 'busymark', 'typst'); + final fonts = Directory(p.join(bundle.path, 'share', 'busymark', 'fonts')); + await Directory(p.dirname(executable)).create(recursive: true); + await fonts.create(recursive: true); + await Link(executable).create(p.absolute(compiler!)); + await Link(p.join(fonts.path, 'bundled')).create( + p.join( + p.dirname(p.dirname(p.dirname(p.absolute(compiler)))), + 'share', + 'busymark', + 'fonts', + ), + ); + await Link(p.join(fonts.path, p.basename(_cjkFont))).create(_cjkFont); + service = MarkdownPdfExportService( + compilerLocator: TypstCompilerLocator( + environment: {'BUSYMARK_TYPST_PATH': executable}, + ), + templateLoader: () => File('assets/export/markdown.typ').readAsString(), + ); + } + + for (final writerside in [false, true]) { + for (final cover in [false, true]) { + for (final toc in [false, true]) { + test( + '${writerside ? "Writerside" : "Markdown"}: cover=$cover toc=$toc section order and native links', + () async { + final options = PdfExportOptions( + includeTitlePage: cover, + content: ExportContentOptions( + includeToc: toc, + tocDepth: 2, + numberHeadings: true, + ), + ); + final bodyIndex = (cover ? 1 : 0) + (toc ? 1 : 0); + final name = + '${writerside ? "writerside" : "markdown"}-$cover-$toc'; + final String path; + if (writerside) { + final fixture = Directory( + 'test/fixtures/writerside/pdf_front_matter', + ).absolute; + final before = { + for (final file + in fixture.listSync(recursive: true).whereType()) + file.path: file.readAsBytesSync(), + }; + path = p.join(root.path, '$name.pdf'); + await WritersidePdfExportService( + markdownExporter: service, + ).export( + WritersidePdfExportRequest( + moduleRoot: fixture.path, + instanceId: 'guide', + destinationPath: path, + overwrite: false, + options: options, + ), + ); + for (final entry in before.entries) { + expect(File(entry.key).readAsBytesSync(), entry.value); + } + } else { + final sourceFile = File(p.join(root.path, 'source.md')); + await sourceFile.writeAsString(_source); + path = await export(name, options); + expect(await sourceFile.readAsString(), _source); + } + final pages = await _pages(path); + expect(pages, hasLength(bodyIndex + 1)); + expect( + pages.indexWhere((page) => page.contains('BODY_START_SENTINEL')), + bodyIndex, + ); + expect(pages.every((page) => page.trim().isNotEmpty), isTrue); + expect(pages[bodyIndex], contains('Original heading')); + expect(pages[bodyIndex], matches(r'1\s+Original heading')); + expect(pages[bodyIndex], matches(r'1\.1\s+Nested heading')); + if (cover) { + expect( + pages.first, + contains( + writerside ? 'Instance cover default' : 'COVER_ONLY_SENTINEL', + ), + ); + expect(pages.first, isNot(contains('Original heading'))); + expect( + pages.first.replaceAll(RegExp(r'\s+'), ' ').trim(), + writerside + ? 'Instance cover default 2.0' + : 'COVER_ONLY_SENTINEL', + ); + } + final xml = await _xml(path); + // Bookmarks survive even when the printed outline is disabled. + expect( + xml + .findAllElements('outline') + .expand((e) => e.findAllElements('item')) + .map((e) => e.innerText), + contains(contains('Original heading')), + ); + if (toc) { + final index = cover ? 1 : 0; + expect(pages[index], contains('Contents')); + expect(pages[index], contains('Nested heading')); + expect(pages[index], isNot(contains('Deep heading'))); + expect(pages[index], isNot(contains('Excluded heading'))); + expect(pages[index], isNot(contains('BODY_SENTINEL'))); + expect(pages[index], isNot(contains('BODY_START_SENTINEL'))); + expect( + pages[index], + matches(RegExp('Nested heading[^\n]*${bodyIndex + 1}')), + ); + final links = xml + .findAllElements('page') + .elementAt(index) + .findAllElements('a') + .toList(); + expect( + links.where((e) => e.innerText.contains('Original heading')), + isNotEmpty, + ); + for (final link in links) { + expect( + link.getAttribute('href'), + endsWith('#${bodyIndex + 1}'), + ); + } + } + if (writerside) { + expect(pages[bodyIndex], contains('HIDDEN_BODY_SENTINEL')); + } + }, + skip: skip, + ); + } + } + } + + test( + 'multipage native outline ends before any body paragraphs; links and page references stay correct', + () async { + final source = List.generate( + 115, + (i) => '# Topic ${i + 1}\n\nPARAGRAPH_${i + 1}_MARKER\n', + ).join('\n'); + final path = await export( + 'multipage-toc', + const PdfExportOptions( + includeTitlePage: true, + pageNumbers: PdfPageNumberPosition.off, + content: ExportContentOptions(includeToc: true), + ), + source: source, + ); + final pages = await _pages(path); + final bodyIndex = pages.indexWhere( + (page) => page.contains('PARAGRAPH_1_MARKER'), + ); + expect(bodyIndex, greaterThanOrEqualTo(3)); + expect(pages.every((page) => page.trim().isNotEmpty), isTrue); + for (final page in pages.take(bodyIndex)) { + expect(page, isNot(contains('PARAGRAPH_'))); + } + final xmlPages = (await _xml(path)).findAllElements('page').toList(); + var headingLinks = 0; + for (var i = 1; i < bodyIndex; i++) { + for (final link in xmlPages[i].findAllElements('a')) { + final match = RegExp(r'Topic (\d+)').firstMatch(link.innerText); + if (match == null) continue; + headingLinks++; + final topic = match[1]!; + final target = + pages.indexWhere( + (page) => page.contains('PARAGRAPH_${topic}_MARKER'), + ) + + 1; + expect(link.getAttribute('href'), endsWith('#$target')); + expect(pages[i], matches(RegExp('Topic $topic\\s[^\n]*$target'))); + } + } + expect(headingLinks, 115); + }, + skip: skip, + ); + + for (final firstPage in [false, true]) { + for (final numbers in [ + PdfPageNumberPosition.bottomCenter, + PdfPageNumberPosition.off, + ]) { + test( + 'cover suppresses running text, physical first=$firstPage numbers=${numbers.name}', + () async { + final options = PdfExportOptions( + includeTitlePage: true, + header: PdfRunningText.documentTitle, + footer: PdfRunningText.documentTitle, + pageNumbers: numbers, + showHeaderFooterOnFirstPage: firstPage, + content: const ExportContentOptions(includeToc: true), + ); + final path = await export( + 'running-$firstPage-${numbers.name}', + options, + ); + final pages = await _pages(path); + expect(pages, hasLength(3)); + expect(pages.first, isNot(contains('RUNNING_TEXT_SENTINEL'))); + final boxes = await _boxes(path); + for (var i = 0; i < 3; i++) { + if (i > 0) { + expect( + RegExp('RUNNING_TEXT_SENTINEL').allMatches(pages[i]), + hasLength(2), + ); + } + final footerWords = _words(boxes[i]).where( + (w) => + double.parse(w.getAttribute('yMin')!) > + options.geometry.heightPt - + options.geometry.margins.bottom * + PdfPageGeometry.pointsPerMm, + ); + expect( + footerWords.where((w) => w.innerText == '${i + 1}'), + hasLength(i > 0 && numbers != PdfPageNumberPosition.off ? 1 : 0), + ); + } + expect(pages[1], matches(r'Original heading[^\n]*3')); + }, + skip: skip, + ); + } + } + + for (final font in ExportBodyTypography.values) { + for (final landscape in [false, true]) { + test( + 'cover wraps Unicode and multiline metadata within ${font.name} ${landscape ? "landscape" : "portrait"} margins', + () async { + final options = PdfExportOptions( + includeTitlePage: true, + bodyTypography: font, + orientation: landscape + ? PdfOrientation.landscape + : PdfOrientation.portrait, + margin: PdfMarginPreset.custom, + customMargins: const PdfMargins( + top: 17, + bottom: 23, + left: 29, + right: 19, + ), + content: const ExportContentOptions(includeToc: true), + ); + final path = await export( + 'layout-${font.name}-$landscape', + options, + cover: const PdfTitlePageData( + title: + 'A long, carefully wrapped title — Résumé of Привет and documentation across multiple platforms', + subtitle: + 'A multiline subtitle\nSecond line with café and Unicode Ω\nPlain #eval("never executed") text', + author: 'Author name', + organization: 'An organization with a longer display name', + version: 'Release candidate v3.0', + date: 'Autumn / someday', + ), + ); + final pages = await _pages(path); + expect(pages, hasLength(3)); + expect(pages.first, contains('Привет')); + expect(pages.first, contains('Second line')); + expect(pages.first, contains('#eval')); + expect(pages.first, contains('Autumn / someday')); + final boxPages = await _boxes(path); + for (final word in _words(boxPages.first)) { + expect( + double.parse(word.getAttribute('xMin')!), + greaterThanOrEqualTo( + options.geometry.margins.left * PdfPageGeometry.pointsPerMm - 1, + ), + ); + expect( + double.parse(word.getAttribute('xMax')!), + lessThanOrEqualTo( + options.geometry.widthPt - + options.geometry.margins.right * + PdfPageGeometry.pointsPerMm + + 1, + ), + ); + expect( + double.parse(word.getAttribute('yMin')!), + greaterThanOrEqualTo( + options.geometry.margins.top * PdfPageGeometry.pointsPerMm - 1, + ), + ); + expect( + double.parse(word.getAttribute('yMax')!), + lessThanOrEqualTo( + options.geometry.heightPt - + options.geometry.margins.bottom * + PdfPageGeometry.pointsPerMm + + 1, + ), + ); + } + final xml = await _xml(path); + final body = xml + .findAllElements('text') + .firstWhere((e) => e.innerText.contains('BODY_START_SENTINEL')); + final bodyFont = xml + .findAllElements('fontspec') + .firstWhere( + (e) => e.getAttribute('id') == body.getAttribute('font'), + ); + expect( + double.parse(bodyFont.getAttribute('size')!), + closeTo(options.bodyFontSize, 1), + ); + expect(bodyFont.getAttribute('color'), isNot(options.accentColor)); + }, + skip: skip, + ); + } + } + + test( + 'request defaults come from source metadata and native outline title stays localized', + () async { + final path = await export( + 'french-defaults', + const PdfExportOptions( + includeTitlePage: true, + content: ExportContentOptions(includeToc: true, tocDepth: 1), + ), + cover: null, + source: _source + .replaceFirst('lang: en', 'lang: fr') + .replaceFirst( + 'author: Source author', + 'author: Source author\nsubtitle: Sous-titre\norganization: Équipe\nversion: 01.020-rc\ndate: Plus tard', + ), + ); + final pages = await _pages(path); + expect(pages, hasLength(3)); + expect(pages.first, contains('RUNNING_TEXT_SENTINEL')); + for (final value in [ + 'Source author', + 'Sous-titre', + 'Équipe', + '01.020-rc', + 'Plus tard', + ]) { + expect(pages.first, contains(value)); + } + expect(pages[1], contains('Table des matières')); + expect(pages[1], isNot(contains('Nested heading'))); + expect(pages[1], matches(r'Original heading[^\n]*3')); + expect(pages[2], contains('BODY_START_SENTINEL')); + }, + skip: skip, + ); + + test( + 'an oversized cover fails clearly and leaves the existing PDF untouched', + () async { + final destination = File(p.join(root.path, 'existing.pdf')); + const original = '%PDF-previous-result'; + await destination.writeAsString(original); + await expectLater( + service.export( + MarkdownPdfExportRequest( + source: _source, + filePath: p.join(root.path, 'source.md'), + workspaceRoot: root.path, + destinationPath: destination.path, + overwrite: true, + options: const PdfExportOptions(includeTitlePage: true), + titlePage: PdfTitlePageData( + title: 'Cannot fit', + subtitle: List.filled(200, 'A subtitle line').join('\n'), + ), + ), + ), + throwsA( + isA().having( + (e) => e.detail, + 'clear fit error', + contains('title page does not fit'), + ), + ), + ); + expect(await destination.readAsString(), original); + final token = MarkdownPdfCancellationToken()..cancel(); + await expectLater( + service.export( + MarkdownPdfExportRequest( + source: _source, + filePath: '', + workspaceRoot: root.path, + destinationPath: destination.path, + overwrite: true, + options: const PdfExportOptions(includeTitlePage: true), + ), + cancellationToken: token, + ), + throwsA( + isA().having( + (e) => e.code, + 'cancelled', + MarkdownPdfFailureCode.cancelled, + ), + ), + ); + expect(await destination.readAsString(), original); + }, + skip: skip, + ); + + for (final font in ExportBodyTypography.values) { + test( + 'Chinese cover without spaces wraps onto two lines in ${font.name}', + () async { + await useCjkFont(); + final options = PdfExportOptions( + includeTitlePage: true, + bodyTypography: font, + pageNumbers: PdfPageNumberPosition.off, + ); + final path = await export( + 'chinese-cover-${font.name}', + options, + source: '---\nlang: zh\n---\nBody.', + cover: const PdfTitlePageData(title: _chineseTitle), + ); + final pages = await _boxes(path); + expect(pages, hasLength(2)); + final words = _words(pages.first).toList(); + expect(words.map((word) => word.innerText).join(), _chineseTitle); + expect( + words.map((word) => word.getAttribute('yMin')).toSet(), + hasLength(2), + ); + _expectInsideContent(pages.first, options.geometry); + }, + skip: File(_cjkFont).existsSync() ? skip : 'Requires fonts-noto-cjk.', + ); + } + + for (final separator in ['\u00a0', '\u2060']) { + test( + 'CJK non-breaking sequence U+${separator.codeUnitAt(0).toRadixString(16)} fails without replacing the PDF', + () async { + await useCjkFont(); + const options = PdfExportOptions(includeTitlePage: true); + final destination = File(await export('nonbreaking-cjk', options)); + final original = await destination.readAsBytes(); + await expectLater( + service.export( + MarkdownPdfExportRequest( + source: '---\nlang: zh\n---\nBody.', + filePath: p.join(root.path, 'source.md'), + workspaceRoot: root.path, + destinationPath: destination.path, + overwrite: true, + options: options, + titlePage: PdfTitlePageData( + title: _chineseTitle.split('').join(separator), + ), + ), + ), + throwsA( + isA() + .having( + (e) => e.code, + 'code', + MarkdownPdfFailureCode.compilerFailed, + ) + .having( + (e) => e.detail, + 'fit error', + contains('title page does not fit'), + ), + ), + ); + expect(await destination.readAsBytes(), original); + }, + skip: File(_cjkFont).existsSync() ? skip : 'Requires fonts-noto-cjk.', + ); + } + + for (final narrow in [false, true]) { + for (final field in [ + 'title', + 'subtitle', + 'author', + 'organization', + 'version', + 'date', + ]) { + test( + 'unbroken $field stays within ${narrow ? "narrow custom" : "A4"} cover bounds or fails without replacing the PDF', + () async { + final options = PdfExportOptions( + includeTitlePage: true, + pageSize: narrow ? PdfPageSize.custom : PdfPageSize.a4, + customWidthMm: 80, + customHeightMm: 150, + margin: PdfMarginPreset.custom, + customMargins: const PdfMargins( + top: 15, + bottom: 15, + left: 20, + right: 15, + ), + pageNumbers: PdfPageNumberPosition.off, + ); + // Wide glyphs cannot be hyphenated; the shorter value only overflows + // with narrow geometry. Neither case requires excessive height. + final narrowLength = switch (field) { + // These fit at body size, but overflow in their rendered styles. + 'title' => 8, + 'subtitle' => 10, + _ => 15, + }; + final value = List.filled(narrow ? narrowLength : 100, 'W').join(); + final cover = PdfTitlePageData( + title: field == 'title' ? value : 'Cover', + subtitle: field == 'subtitle' ? value : '', + author: field == 'author' ? value : '', + organization: field == 'organization' ? value : '', + version: field == 'version' ? value : '', + date: field == 'date' ? value : '', + ); + final destination = File( + await export( + 'unbroken-$field-$narrow', + options, + source: 'Body.', + cover: const PdfTitlePageData(title: 'Cover'), + ), + ); + final original = await destination.readAsBytes(); + try { + await service.export( + MarkdownPdfExportRequest( + source: 'Body.', + filePath: p.join(root.path, 'source.md'), + workspaceRoot: root.path, + destinationPath: destination.path, + overwrite: true, + options: options, + titlePage: cover, + ), + ); + } on MarkdownPdfExportException catch (error) { + expect(error.code, MarkdownPdfFailureCode.compilerFailed); + expect(error.detail, contains('title page does not fit')); + expect(await destination.readAsBytes(), original); + return; + } + final pages = await _boxes(destination.path); + expect(pages, hasLength(2)); + // Bounds alone could miss text clipped outside the physical page. + final coverWords = _words(pages.first).toList(); + expect(coverWords, isNotEmpty); + expect( + coverWords.map((word) => word.innerText).join(), + field == 'title' ? value : 'Cover$value', + ); + for (final page in pages) { + _expectInsideContent(page, options.geometry); + } + }, + skip: skip, + ); + } + } +} + +void _expectInsideContent(XmlElement page, PdfPageGeometry geometry) { + final left = geometry.margins.left * PdfPageGeometry.pointsPerMm; + final top = geometry.margins.top * PdfPageGeometry.pointsPerMm; + for (final word in _words(page)) { + for (final (attribute, minimum, maximum) in [ + ('xMin', left, left + geometry.contentWidthPt), + ('xMax', left, left + geometry.contentWidthPt), + ('yMin', top, top + geometry.contentHeightPt), + ('yMax', top, top + geometry.contentHeightPt), + ]) { + expect( + double.parse(word.getAttribute(attribute)!), + inInclusiveRange(minimum - 1, maximum + 1), + reason: + '$attribute of ${word.innerText} must stay inside the content area', + ); + } + } +} + +Future> _pages(String path) async { + final result = await Process.run('/usr/bin/pdftotext', [ + '-layout', + path, + '-', + ]); + expect(result.exitCode, 0, reason: '${result.stderr}'); + final pages = (result.stdout as String).split('\f'); + if (pages.last.trim().isEmpty) pages.removeLast(); + return pages; +} + +Future _xml(String path) async { + final result = await Process.run('/usr/bin/pdftohtml', [ + '-xml', + '-i', + '-zoom', + '1', + '-stdout', + path, + ]); + expect(result.exitCode, 0, reason: '${result.stderr}'); + return XmlDocument.parse(result.stdout as String); +} + +Future> _boxes(String path) async { + final result = await Process.run('/usr/bin/pdftotext', ['-bbox', path, '-']); + expect(result.exitCode, 0); + return XmlDocument.parse(result.stdout as String).descendants + .whereType() + .where((e) => e.name.local == 'page') + .toList(); +} + +Iterable _words(XmlElement page) => page.descendants + .whereType() + .where((e) => e.name.local == 'word'); diff --git a/test/src/pdf_title_page_test.dart b/test/src/pdf_title_page_test.dart new file mode 100644 index 00000000..8066128b --- /dev/null +++ b/test/src/pdf_title_page_test.dart @@ -0,0 +1,205 @@ +import 'dart:convert'; + +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/export/markdown_export_mapper.dart'; +import 'package:busymark/src/export/markdown_pdf_models.dart'; +import 'package:busymark/src/export/typst_payload_builder.dart'; +import 'package:busymark/src/markdown/busymark_document.dart'; +import 'package:busymark/src/markdown/markdown_model.dart'; +import 'package:busymark/src/markdown/markdown_parser.dart'; +import 'package:busymark/src/writerside/writerside_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'cover preference defaults off, serializes and copies independently', + () { + const defaults = PdfExportOptions(); + expect(defaults.includeTitlePage, isFalse); + expect(defaults.content.includeToc, isFalse); + expect(PdfExportOptions.fromJson({}).includeTitlePage, isFalse); + expect( + PdfExportOptions.fromJson({ + 'includeTitlePage': 'true', + }).includeTitlePage, + isFalse, + ); + final options = defaults.copyWith(includeTitlePage: true); + expect(options.copyWith(bodyFontSize: 14).includeTitlePage, isTrue); + expect( + options.copyWith(includeTitlePage: false).toJson(), + defaults.toJson(), + ); + final stored = AppSettings.defaults().copyWith(pdfExportOptions: options); + final loaded = AppSettings.fromJson( + jsonDecode(jsonEncode(stored.toJson())) as Map, + ); + expect(loaded.pdfExportOptions.includeTitlePage, isTrue); + expect( + loaded.htmlExportOptions.toJson(), + const HtmlExportOptions().toJson(), + ); + expect(options.toJson().keys, isNot(contains('titlePage'))); + expect( + const ExportContentOptions().toJson().keys, + isNot(contains('includeTitlePage')), + ); + }, + ); + + test('cover title and authors use the established metadata precedence', () { + const document = BusyDocument( + filePath: '/docs/Filename.md', + mode: MarkdownMode.commonMark, + title: 'Document title', + frontMatter: {'title': 'Metadata title', 'authors': 'Ada, Lin'}, + blocks: [], + ); + expect( + PdfTitlePageData.fromDocument(document, titleOverride: 'Override').title, + 'Override', + ); + expect(PdfTitlePageData.fromDocument(document).title, 'Metadata title'); + expect(PdfTitlePageData.fromDocument(document).author, 'Ada, Lin'); + expect( + PdfTitlePageData.fromDocument( + document.copyWith( + frontMatter: {'author': 'First', 'authors': 'Second'}, + ), + ).author, + 'First', + ); + expect( + PdfTitlePageData.fromDocument(document.copyWith(frontMatter: {})).title, + 'Document title', + ); + expect( + PdfTitlePageData.fromDocument( + document.copyWith(frontMatter: {}, title: ''), + ).title, + 'Filename', + ); + expect( + PdfTitlePageData.fromDocument( + const BusyDocument( + filePath: '', + mode: MarkdownMode.commonMark, + blocks: [], + ), + ).title, + 'Untitled', + ); + }); + + test( + 'snapshot metadata, display text, explicit clears and plain JSON remain independent', + () { + final document = const MarkdownParser() + .parse( + source: '''--- +title: Unsaved title +subtitle: Résumé and Ω +author: Ada +organization: Example team +version: 01.020-rc +date: Whenever / later +--- +# Original source heading + +Body. +''', + filePath: '/docs/snapshot.md', + ) + .busyDocument; + final defaults = PdfTitlePageData.fromDocument(document); + expect(defaults.toJson(), { + 'title': 'Unsaved title', + 'subtitle': 'Résumé and Ω', + 'author': 'Ada', + 'organization': 'Example team', + 'version': '01.020-rc', + 'date': 'Whenever / later', + }); + final edited = defaults.copyWith( + title: '#eval("plain text")', + subtitle: 'Line one\nLine two', + author: '', + organization: ' ', + version: '', + date: '', + ); + expect(edited.validate(), isEmpty); + expect(edited.toJson(), { + 'title': '#eval("plain text")', + 'subtitle': 'Line one\nLine two', + }); + expect( + edited.copyWith(title: ' \n').validate().single.field, + 'titlePage.title', + ); + final export = const MarkdownExportMapper().map(document); + final payload = const TypstPayloadBuilder().build( + document: export, + options: const PdfExportOptions(includeTitlePage: true), + titlePage: edited, + assets: {}, + ); + expect(payload['titlePage'], edited.toJson()); + expect( + jsonDecode(jsonEncode(payload))['titlePage']['title'], + '#eval("plain text")', + ); + expect( + const TypstPayloadBuilder() + .build( + document: export, + options: const PdfExportOptions(), + titlePage: edited, + assets: {}, + ) + .containsKey('titlePage'), + isFalse, + ); + expect( + () => const TypstPayloadBuilder().build( + document: export, + options: const PdfExportOptions(includeTitlePage: true), + titlePage: edited.copyWith(title: ''), + assets: {}, + ), + throwsA(isA()), + ); + expect(defaults.title, 'Unsaved title'); + final next = PdfTitlePageData.fromDocument( + const BusyDocument( + filePath: '/docs/Next.md', + mode: MarkdownMode.commonMark, + blocks: [], + ), + ); + expect(next.toJson(), {'title': 'Next'}); + }, + ); + + test( + 'instance title uses its display name and only known project metadata', + () { + const instance = WritersideInstance( + id: 'guide', + name: 'Whole instance', + sourceTreePath: '/project/guide.tree', + startPage: 'Topic with a different name.topic', + status: '', + isLibrary: false, + tocRoots: [], + diagnostics: [], + globalVersion: 'Project version', + version: 'Instance version', + ); + expect(PdfTitlePageData.forInstance(instance).toJson(), { + 'title': 'Whole instance', + 'version': 'Instance version', + }); + }, + ); +} diff --git a/test/src/pdf_title_page_ui_test.dart b/test/src/pdf_title_page_ui_test.dart new file mode 100644 index 00000000..8dd059e8 --- /dev/null +++ b/test/src/pdf_title_page_ui_test.dart @@ -0,0 +1,433 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:busymark/l10n/generated/app_localizations.dart'; +import 'package:busymark/src/app/app_settings.dart'; +import 'package:busymark/src/app/app_theme.dart'; +import 'package:busymark/src/app/busymark_design.dart'; +import 'package:busymark/src/export/export_options_editor.dart'; +import 'package:busymark/src/export/markdown_pdf_export_service.dart'; +import 'package:busymark/src/export/markdown_pdf_export_ui.dart'; +import 'package:busymark/src/export/markdown_pdf_models.dart'; +import 'package:busymark/src/export/workspace_export_ui.dart'; +import 'package:busymark/src/export/writerside_pdf_export_service.dart'; +import 'package:busymark/src/export/writerside_pdf_export_ui.dart'; +import 'package:busymark/src/export/writerside_pdf_models.dart'; +import 'package:busymark/src/platform/linux_header_bar_service.dart'; +import 'package:busymark/src/workspace/workspace_controller.dart'; +import 'package:busymark/src/writerside/writerside_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +const _defaults = PdfTitlePageData( + title: 'Document default', + author: 'Metadata author', + version: 'Draft version', +); + +void main() { + testWidgets( + 'cover edits survive toggling and format changes, validate, reset, and do not leak through preferences', + (tester) async { + final settings = _Settings(); + PdfTitlePageData defaults = _defaults; + ExportOptionsSelection? selection; + await tester.pumpWidget( + _harness(settings, (context, ref) async { + selection = await showExportOptions( + context, + ref, + pdfTitlePageDefaults: defaults, + ); + }), + ); + await _open(tester); + expect(_field('Title'), findsNothing); + await _toggle(tester, true); + expect(_text(tester, 'Title'), 'Document default'); + await _edit(tester, 'Title', ''); + expect(find.text('Enter a title for the title page.'), findsWidgets); + expect( + tester + .widget( + find.byKey(const ValueKey('export-options-submit')), + ) + .onPressed, + isNull, + ); + await _edit(tester, 'Title', 'Export-specific title'); + await _edit(tester, 'Author', ''); + await _toggle(tester, false); + expect(_field('Title'), findsNothing); + await _toggle(tester, true); + expect(_text(tester, 'Title'), 'Export-specific title'); + expect(_text(tester, 'Author'), ''); + tester + .widget>( + find.byType(BusyMarkComboRow), + ) + .onSelected(ExportFormat.html); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('pdf-include-title-page')), + findsNothing, + ); + expect(_field('Title'), findsNothing); + final html = tester.widget( + find.byType(HtmlExportOptionsEditor), + ); + html.onChanged(html.value.copyWith(baseFontSize: 21)); + tester + .widget>( + find.byType(BusyMarkComboRow), + ) + .onSelected(ExportFormat.pdf); + await tester.pumpAndSettle(); + expect(_text(tester, 'Title'), 'Export-specific title'); + expect(_text(tester, 'Author'), ''); + await tester.ensureVisible(find.text('Reset to defaults')); + await tester.tap(find.text('Reset to defaults')); + await tester.pumpAndSettle(); + expect(_field('Title'), findsNothing); + expect( + tester + .widget(find.byType(PdfExportOptionsEditor)) + .value + .includeTitlePage, + isFalse, + ); + await _toggle(tester, true); + expect(_text(tester, 'Title'), 'Document default'); + expect(_text(tester, 'Author'), 'Metadata author'); + await _edit(tester, 'Title', 'Only this export'); + await _edit(tester, 'Author', ''); + await tester.tap(find.byKey(const ValueKey('export-options-submit'))); + await _settle(tester); + expect(selection!.titlePage!.title, 'Only this export'); + expect(selection!.titlePage!.author, ''); + expect( + AppSettings.fromJson(settings.data).pdfExportOptions.includeTitlePage, + isTrue, + ); + expect(settings.data.toString(), isNot(contains('Only this export'))); + expect( + AppSettings.fromJson(settings.data).htmlExportOptions.baseFontSize, + 17, + ); + + defaults = const PdfTitlePageData(title: 'Unrelated document'); + await _open(tester); + expect(_text(tester, 'Title'), 'Unrelated document'); + expect(_text(tester, 'Author'), ''); + final beforeCancel = settings.data.toString(); + await _edit(tester, 'Title', 'Cancelled title'); + await _toggle(tester, false); + await tester.tap(find.text('Cancel')); + await _settle(tester); + expect(selection, isNull); + expect(settings.data.toString(), beforeCancel); + await _open(tester); + expect(_text(tester, 'Title'), 'Unrelated document'); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }, + ); + + testWidgets( + 'instance changes refresh automatic fields without replacing edited or cleared text', + (tester) async { + final instances = [ + for (final id in ['a', 'b']) + WritersideInstance( + id: id, + name: 'Instance $id', + version: 'Version $id', + sourceTreePath: '$id.tree', + startPage: null, + status: '', + isLibrary: false, + tocRoots: const [], + diagnostics: const [], + ), + ]; + ExportOptionsSelection? selection; + await tester.pumpWidget( + _harness(_Settings(), (context, ref) async { + selection = await showExportOptions( + context, + ref, + instances: instances, + pdfTitlePageDefaults: const PdfTitlePageData( + title: 'Must not use open topic', + ), + ); + }), + ); + await _open(tester); + await _toggle(tester, true); + expect(_text(tester, 'Title'), 'Instance a'); + tester + .widget>( + find.byType(BusyMarkComboRow), + ) + .onSelected(instances[1]); + await tester.pumpAndSettle(); + expect(_text(tester, 'Title'), 'Instance b'); + expect(_text(tester, 'Version'), 'Version b'); + await _edit(tester, 'Title', 'Edited instance cover'); + await _edit(tester, 'Version', ''); + tester + .widget>( + find.byType(BusyMarkComboRow), + ) + .onSelected(instances[0]); + await tester.pumpAndSettle(); + expect(_text(tester, 'Title'), 'Edited instance cover'); + expect(_text(tester, 'Version'), ''); + await tester.tap(find.byKey(const ValueKey('export-options-submit'))); + await _settle(tester); + expect(selection!.instance!.id, 'a'); + expect(selection!.titlePage!.title, 'Edited instance cover'); + expect(selection!.titlePage!.version, ''); + }, + ); + + testWidgets( + 'settings without a document show only the reusable cover preference', + (tester) async { + await tester.pumpWidget( + _harness(_Settings(), (context, ref) async { + await showExportOptions(context, ref); + }), + ); + await _open(tester); + await _toggle(tester, true); + expect(_field('Title'), findsNothing); + expect(_field('Author'), findsNothing); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }, + ); + + for (final unified in [false, true]) { + for (final writerside in [false, true]) { + testWidgets( + '${unified ? "Unified" : "Direct"} PDF ${writerside ? "Writerside" : "Markdown"} forwards current defaults and export-owned text', + (tester) async { + final root = Directory.systemTemp.createTempSync('pdf-title-ui-'); + addTearDown(() => root.deleteSync(recursive: true)); + final markdown = _MarkdownExporter(); + final instance = _WritersideExporter(); + final picker = Completer(); + const channel = MethodChannel('plugins.flutter.io/file_selector'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (_) => picker.future, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + late WidgetRef ref; + await tester.pumpWidget( + _harness( + _Settings(), + (context, value) => unified + ? exportWorkspace(context, value) + : exportWorkspaceToPdf(context, value), + ready: (value) => ref = value, + markdown: markdown, + writerside: instance, + ), + ); + final controller = ref.read(workspaceControllerProvider.notifier); + if (writerside) { + await tester.runAsync( + () => controller.openPath( + Directory( + 'test/fixtures/writerside/pdf_front_matter', + ).absolute.path, + ), + ); + await tester.runAsync( + () => controller.openActiveFile( + File( + 'test/fixtures/writerside/pdf_front_matter/topics/body.topic', + ).absolute.path, + ), + ); + } else { + await tester.runAsync(controller.createMarkdownFile); + controller.updateActiveText( + '---\ntitle: Unsaved cover title\nauthor: Unsaved author\n---\n# Unsaved body\n', + ); + } + await _open(tester); + await _toggle(tester, true); + expect( + _text(tester, 'Title'), + writerside ? 'Instance cover default' : 'Unsaved cover title', + ); + expect(_text(tester, 'Author'), writerside ? '' : 'Unsaved author'); + await _edit(tester, 'Subtitle', 'Only this request'); + await _edit(tester, 'Author', ''); + // Changing the live Markdown editor while the dialog is open must not + // separate the exported body from its already captured metadata. + if (!writerside) { + controller.updateActiveText('# Later editor revision'); + } + await tester.tap(find.byKey(const ValueKey('export-options-submit'))); + await _settle(tester); + picker.complete(p.join(root.path, 'export.pdf')); + for (var i = 0; i < 20; i++) { + await _settle(tester); + if (markdown.request != null || instance.request != null) break; + } + final titlePage = writerside + ? instance.request!.titlePage! + : markdown.request!.titlePage!; + expect(titlePage.subtitle, 'Only this request'); + expect(titlePage.author, ''); + if (writerside) { + expect(instance.request!.instanceId, 'guide'); + expect(instance.request!.options.includeTitlePage, isTrue); + } else { + expect(markdown.request!.source, contains('# Unsaved body')); + expect( + markdown.request!.document!.frontMatter['title'], + 'Unsaved cover title', + ); + expect(markdown.request!.options.includeTitlePage, isTrue); + expect( + ref.read(workspaceControllerProvider).activeText, + '# Later editor revision', + ); + } + await tester.pump(const Duration(seconds: 8)); + await tester.pumpWidget(const SizedBox()); + }, + ); + } + } +} + +Finder _field(String label) => find.byWidgetPredicate( + (w) => w is BusyMarkGroupedTextEntry && w.label == label, +); +Finder _input(String label) => + find.descendant(of: _field(label), matching: find.byType(TextField)); +String _text(WidgetTester tester, String label) => + tester.widget(_input(label)).controller!.text; +Future _edit(WidgetTester tester, String label, String value) async { + await tester.ensureVisible(_input(label)); + await tester.enterText(_input(label), value); + await tester.pumpAndSettle(); +} + +Future _toggle(WidgetTester tester, bool value) async { + tester + .widget( + find.byKey(const ValueKey('pdf-include-title-page')), + ) + .onChanged(value); + await tester.pumpAndSettle(); +} + +Future _open(WidgetTester tester) async { + await tester.tap(find.text('Run export')); + await _settle(tester); +} + +Future _settle(WidgetTester tester) async { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 70)), + ); + await tester.pumpAndSettle(); +} + +Widget _harness( + _Settings settings, + Future Function(BuildContext, WidgetRef) action, { + void Function(WidgetRef)? ready, + MarkdownPdfExportService? markdown, + WritersidePdfExportService? writerside, +}) => ProviderScope( + overrides: [ + localSettingsStoreProvider.overrideWithValue(settings), + linuxHeaderBarServiceProvider.overrideWithValue( + LinuxHeaderBarService( + channel: const MethodChannel('pdf-title-test-header'), + ), + ), + if (markdown != null) + markdownPdfExportServiceProvider.overrideWithValue(markdown), + if (writerside != null) + writersidePdfExportServiceProvider.overrideWithValue(writerside), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildBusyMarkTheme( + brightness: Brightness.light, + accentColor: Colors.blue, + ), + home: Scaffold( + body: Consumer( + builder: (context, ref, _) { + ready?.call(ref); + return TextButton( + onPressed: () => action(context, ref), + child: const Text('Run export'), + ); + }, + ), + ), + ), +); + +class _Settings implements LocalSettingsStore { + Map data = AppSettings.defaults() + .copyWith(autoSave: false) + .toJson(); + @override + Future> load() async => data; + @override + Future save(Map json) async { + data = json; + } +} + +class _MarkdownExporter extends MarkdownPdfExportService { + MarkdownPdfExportRequest? request; + @override + Future export( + MarkdownPdfExportRequest request, { + MarkdownPdfCancellationToken? cancellationToken, + }) async { + this.request = request; + return MarkdownPdfExportResult( + destinationPath: request.destinationPath, + pageCount: 2, + warnings: const [], + ); + } +} + +class _WritersideExporter extends WritersidePdfExportService { + WritersidePdfExportRequest? request; + @override + Future export( + WritersidePdfExportRequest request, { + WritersidePdfCancellationToken? cancellationToken, + }) async { + this.request = request; + return WritersidePdfExportResult( + destinationPath: request.destinationPath, + pageCount: 2, + warnings: const [], + ); + } +} diff --git a/test/src/source_audit_test.dart b/test/src/source_audit_test.dart index 35ad28e2..40f4ff1f 100644 --- a/test/src/source_audit_test.dart +++ b/test/src/source_audit_test.dart @@ -509,7 +509,8 @@ void main() { expect(workspace, contains('_showProblemsDialog')); expect(workspace, contains('_validateActiveAndShowProblems')); - expect(workspace, contains(').validateActive()')); + expect(workspace, contains('await controller.validateActive()')); + expect(workspace, contains('controller.isCurrentValidation(outcome)')); expect(workspace, contains('class _ProblemsList')); expect(workspace, isNot(contains("tooltip: 'Problems'"))); expect(workspace, isNot(contains('_ProblemsPanel'))); diff --git a/test/src/source_editor_widget_test.dart b/test/src/source_editor_widget_test.dart index 3f7eb7b0..c70e96ab 100644 --- a/test/src/source_editor_widget_test.dart +++ b/test/src/source_editor_widget_test.dart @@ -1877,6 +1877,64 @@ void main() { ); }); + testWidgets('structured history restores a local video into Source', ( + tester, + ) async { + final root = (await tester.runAsync( + () => Directory.systemTemp.createTemp('busymark-source-history-video-'), + ))!; + addTearDown(() async { + if (await root.exists()) await root.delete(recursive: true); + }); + final destinationPath = '${root.path}/target.md'; + final fragment = _structuredVideoFragment('/original/topic.md'); + final registry = BusyMarkClipboardInsertionRegistry(); + addTearDown(registry.dispose); + String? changed; + await _pumpClipboardSourceEditor( + tester, + source: 'Target\n', + clipboard: _SourceTestClipboard(), + registry: registry, + filePath: destinationPath, + assetWorkspaceKind: AssetWorkspaceKind.standalone, + onChanged: (value, _) => changed = value, + ); + final video = Uint8List.fromList([ + 0, + 0, + 0, + 20, + ...ascii.encode('ftypmp42'), + 0, + 0, + 0, + 0, + ]); + final payload = BusyMarkClipboardPayload( + id: 'source-rich-video', + acquiredAt: DateTime.utc(2026), + kind: BusyMarkClipboardContentKind.richText, + text: 'Local clip', + sourceText: '