Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1110f83
Bump version to v0.4.1
albert-gee Sep 13, 2026
600f365
Restore Export to the main menu
albert-gee Sep 13, 2026
914f4e2
Add support for anchors in list items and handle inline content in gr…
albert-gee Sep 13, 2026
e064f36
Add `listId` support for lists to enhance export capabilities. Handle…
albert-gee Sep 13, 2026
f94d046
Add `toBusyDocument` method and improve list ID handling for consiste…
albert-gee Sep 13, 2026
94db0f8
Refactor PDF export service to improve diagnostic reporting with deta…
albert-gee Sep 13, 2026
bb3eb18
Improve anchor resolution for Writerside documents and Markdown parsi…
albert-gee Sep 13, 2026
8154637
Add support for PDF title page customization, including title, subtit…
albert-gee Sep 13, 2026
1ba18a8
Improve title page rendering in PDF export by adding line overflow ha…
albert-gee Sep 13, 2026
8230ac3
Fixed cover layout (assets/export/markdown.typ:459) to preserve Typst…
albert-gee Sep 13, 2026
79f671e
Fixed issues:
albert-gee Sep 13, 2026
2611f59
Add support for footnote targets and improved occurrence tracking in …
albert-gee Sep 13, 2026
2677b1a
Enhance asset ingestion and clipboard handling:
albert-gee Sep 13, 2026
37eccad
Add spacing widget to export options editor for consistent layout spa…
albert-gee Sep 13, 2026
3eed221
Fixed table row validation
albert-gee Sep 13, 2026
1dbaa16
Introduce validation status tracking and refactor validation logic:
albert-gee Sep 13, 2026
c9c84ad
Refactor workspace reparse logic to use `_reparseWithDocumentBuffers`:
albert-gee Sep 13, 2026
f836d89
Enhance authored HTML diagnostics in Markdown processing:
albert-gee Sep 14, 2026
84d1ccd
Refactor test utilities and validation logic:
albert-gee Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/flutter-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ jobs:
clang \
cmake \
fonts-noto-core \
fonts-noto-cjk \
fonts-noto-mono \
ninja-build \
pkg-config \
Expand Down
153 changes: 137 additions & 16 deletions assets/export/markdown.typ
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
})
}
} },
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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", ())
Expand All @@ -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(
Expand Down Expand Up @@ -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"
Expand All @@ -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,
Expand All @@ -356,7 +397,7 @@
if item.kind == "image" {
render-display-image(item)
} else {
render-inlines((item,))
render-inlines((item,), render-block)
}
}
}
Expand Down Expand Up @@ -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) }
63 changes: 63 additions & 0 deletions docs/development/export-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions docs/html-export.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading