Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ Source repository: https://github.com/busystack/busymark
The BusyMark name and logo are identifying marks of BusyStack.
This license does not grant permission to use the BusyMark name or logo to
imply endorsement, sponsorship, or official status for modified versions.

BusyMark includes the Typst compiler (https://typst.app/), distributed under
the Apache License 2.0. Its license and upstream notices are installed with the
application under share/licenses/typst.
33 changes: 29 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ projects.
- Create Writerside Markdown and XML topics from the TOC.
- Edit and save local files.
- Preview Markdown content.
- Export Markdown documents as accessible, tagged PDF files.
- Navigate project files, table of contents, and document outline.
- Run basic diagnostics.
- Reopen recent workspaces.
Expand Down Expand Up @@ -83,6 +84,19 @@ Large folders are scanned defensively. Generated and vendor directories such as
`.git`, `build`, `dist`, `node_modules`, `.dart_tool`, `.gradle`, and `target`
are skipped to keep the app responsive.

## PDF export

Use **Main menu → Export as PDF** or <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd>
while a Markdown document is active. BusyMark exports the current editor
contents, including unsaved changes, and offers A4 or Letter paper, portrait or
landscape orientation, three margin sizes, and optional page numbers. Writerside
topics are not exported yet.

PDF generation is local and offline. BusyMark bundles the pinned Typst compiler;
users do not install or configure a separate program. Local PNG, JPEG, GIF, and
safe SVG images are included. Remote images are deliberately not downloaded
during export and are represented by their alternative text.

## Run From Source

1. [Install Flutter](https://docs.flutter.dev/install)
Expand Down Expand Up @@ -196,14 +210,24 @@ Store listing translations are managed outside `snap/snapcraft.yaml`.

## Build Linux Locally

Source builds require the libhandy development headers. Packaged users receive
the runtime library with BusyMark and do not install development packages.
Source builds require the libhandy development headers, `curl`, and `xz-utils`.
Packaged users receive every runtime component with BusyMark and do not install
development packages.

```bash
sudo apt-get install libhandy-1-dev
sudo apt-get install curl libhandy-1-dev xz-utils
flutter build linux
```

The Linux build downloads the matching x86_64 or ARM64 Typst 0.15.1 binary from
its official release and verifies its pinned SHA-256 checksum before bundling
it. For an offline build, point the build at the matching official archive:

```bash
BUSYMARK_TYPST_ARCHIVE=/path/to/typst-x86_64-unknown-linux-musl.tar.xz \
flutter build linux
```

The Linux desktop file uses the application id `io.busystack.busymark` and
installs the app icon from `assets/branding/busymark_logo.svg`.

Expand All @@ -215,4 +239,5 @@ handling, and clear user-facing behavior.

## License

Apache-2.0. See [LICENSE](LICENSE).
Apache-2.0. See [LICENSE](LICENSE). The bundled Typst compiler's license and
upstream notices are installed under `share/licenses/typst`.
241 changes: 241 additions & 0 deletions assets/export/markdown.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// BusyMark Markdown PDF renderer. All document data enters through JSON;
// Markdown text is never evaluated as Typst source.
#let data = json("document.json")
#let metadata = data.metadata
#let options = data.options

#set document(
title: metadata.title,
author: if metadata.author == "" { () } else { (metadata.author,) },
description: metadata.description,
keywords: metadata.keywords,
)
#set page(
paper: options.paper,
flipped: options.landscape,
margin: (
x: options.marginHorizontalPt * 1pt,
y: options.marginVerticalPt * 1pt,
),
numbering: if options.pageNumbers { "1" } else { none },
number-align: center + bottom,
)
#set text(
fallback: true,
size: 10.5pt,
lang: metadata.language,
)
#set par(leading: 0.68em)
#set heading(numbering: none)
#show heading.where(level: 1): set text(size: 22pt, weight: "bold")
#show heading.where(level: 2): set text(size: 17pt, weight: "bold")
#show heading.where(level: 3): set text(size: 13.5pt, weight: "bold")
#show heading.where(level: 4): set text(size: 11.5pt, weight: "bold")
#show link: set text(fill: rgb("2563a5"))

#let value-or(item, key, default) = item.at(key, default: default)

#let render-inlines(items) = {
for item in items {
let kind = item.kind
let children = value-or(item, "children", ())
let body = if children.len() > 0 {
render-inlines(children)
} else {
text(value-or(item, "text", ""))
}

if kind == "text" {
body
} else if kind == "strong" {
strong(body)
} else if kind == "emphasis" {
emph(body)
} else if kind == "underline" {
underline(body)
} else if kind == "strikethrough" {
strike(body)
} else if kind == "code" {
raw(value-or(item, "text", ""))
} else if kind == "link" {
let destination = value-or(item, "destination", "")
if destination == "" {
body
} else if destination.starts-with("#") {
link(label(destination.slice(1)), body)
} else {
link(destination, body)
}
} else if kind == "image" {
let asset = value-or(item, "asset", "")
let alt = value-or(item, "alt", "")
if asset == "" {
emph(text(if alt == "" { "[Image unavailable]" } else { "[Image: " + alt + "]" }))
} else {
image(asset, width: 1.25em, height: 1.25em, fit: "contain", alt: alt)
}
} else if kind == "softBreak" {
text(" ")
} else if kind == "hardBreak" {
linebreak()
} else {
body
}
}
}

#let render-display-image(item) = {
let asset = value-or(item, "asset", "")
let alt = value-or(item, "alt", "")
if asset == "" {
emph(text(if alt == "" { "[Image unavailable]" } else { "[Image: " + alt + "]" }))
} else {
layout(size => image(
asset,
width: 100%,
height: 72% * size.height,
fit: "contain",
alt: alt,
))
}
}

#let render-table(block-data) = {
let rows = value-or(block-data, "children", ())
if rows.len() == 0 {
none
} else {
let column-count = calc.max(..rows.map(row => value-or(row, "children", ()).len()))
let cells = ()
for row in rows {
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", ()))
let alignment = value-or(cell, "align", "left")
let cell-align = if alignment == "center" {
center
} else if alignment == "right" {
right
} else {
left
}
row-cells.push(table.cell(
align: cell-align,
if is-header { strong(cell-body) } else { cell-body },
))
}
for ignored in range(value-or(row, "children", ()).len(), column-count) {
row-cells.push(none)
}
if is-header {
cells.push(table.header(..row-cells))
} else {
for cell in row-cells { cells.push(cell) }
}
}
table(
columns: column-count,
inset: 5pt,
stroke: 0.5pt + rgb("c8cdd4"),
fill: (x, y) => if y == 0 { rgb("f1f3f5") } else { none },
..cells,
)
}
}

#let render-list(block-data, render) = {
let items = value-or(block-data, "children", ()).map(item => {
let task = value-or(item, "task", none)
if task != none {
box(width: 1.35em, text(if task { "☑" } else { "☐" }))
}
render-inlines(value-or(item, "inlines", ()))
let nested = value-or(item, "children", ())
if nested.len() > 0 {
for nested-block in nested { render(nested-block) }
}
})
if value-or(block-data, "ordered", false) {
enum(start: value-or(block-data, "start", 1), ..items)
} else {
list(..items)
}
}

#let render-block(block-data) = {
let kind = block-data.kind
let inlines = value-or(block-data, "inlines", ())
let children = value-or(block-data, "children", ())
if kind == "heading" {
let element = heading(
level: value-or(block-data, "level", 1),
outlined: true,
render-inlines(inlines),
)
let anchor = value-or(block-data, "id", "")
if anchor == "" { element } else { [#element #label(anchor)] }
} else if kind == "paragraph" {
par(render-inlines(inlines))
} else if kind == "code" {
let language = value-or(block-data, "language", "")
block(
width: 100%,
fill: rgb("f4f5f7"),
inset: 8pt,
radius: 3pt,
if language == "" {
raw(value-or(block-data, "text", ""), block: true)
} else {
raw(value-or(block-data, "text", ""), block: true, lang: language)
},
)
} else if kind == "list" {
render-list(block-data, render-block)
} else if kind == "blockquote" {
quote(
block: true,
if children.len() > 0 {
for child in children { render-block(child) }
} else {
render-inlines(inlines)
},
)
} else if kind == "thematicBreak" {
block(above: 0.8em, below: 0.8em, line(length: 100%, stroke: 0.6pt + rgb("aeb4bc")))
} else if kind == "image" {
let image-body = {
for item in inlines {
if item.kind == "image" {
render-display-image(item)
} else {
render-inlines((item,))
}
}
}
let destination = value-or(block-data, "destination", "")
align(center, if destination == "" {
image-body
} else if destination.starts-with("#") {
link(label(destination.slice(1)), image-body)
} else {
link(destination, image-body)
})
} else if kind == "table" {
render-table(block-data)
} else if kind == "rawText" {
block(
width: 100%,
fill: rgb("f7f7f8"),
inset: 7pt,
radius: 3pt,
raw(value-or(block-data, "text", ""), block: true),
)
} else if kind == "group" {
for child in children { render-block(child) }
} else {
render-inlines(inlines)
}
}

#for block-data in data.blocks { render-block(block-data) }
25 changes: 24 additions & 1 deletion lib/l10n/app_ar.arb
Original file line number Diff line number Diff line change
Expand Up @@ -2381,6 +2381,29 @@
"errorWritersideRedirectInvalid": "لم يعد هدف إعادة التوجيه المحدد صالحًا. حدده مرة أخرى.",
"@errorWritersideRedirectInvalid": {"description": "Error shown when a safe-delete redirect target is stale or conflicts."},
"errorWritersideRollbackFailed": "تعذّر التراجع بالكامل عن إزالة الموضوع. راجع هذه المسارات قبل المتابعة: \u2068{paths}\u2069",
"@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}}
"@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}},
"exportAsPdf": "تصدير بصيغة PDF", "@exportAsPdf": {"description": "Menu action and dialog title for exporting the active Markdown document as PDF."},
"pdfExportDescription": "اختر تخطيط الصفحة لإنشاء ملف PDF متقن ومستقل.", "@pdfExportDescription": {"description": "Introductory text in the PDF export options dialog."},
"pdfRemoteImagesNote": "لا تُنزّل الصور البعيدة أثناء التصدير. تُضمّن الصور المحلية عند توفرها.", "@pdfRemoteImagesNote": {"description": "Privacy note explaining image handling during PDF export."},
"pdfPageSize": "حجم الصفحة", "@pdfPageSize": {"description": "PDF export page-size setting."},
"pdfPageSizeA4": "A4", "@pdfPageSizeA4": {"description": "A4 PDF page-size option."},
"pdfPageSizeLetter": "Letter (رسائل)", "@pdfPageSizeLetter": {"description": "US Letter PDF page-size option."},
"pdfOrientation": "الاتجاه", "@pdfOrientation": {"description": "PDF export page-orientation setting."},
"pdfPortrait": "عمودي", "@pdfPortrait": {"description": "Portrait PDF page-orientation option."},
"pdfLandscape": "أفقي", "@pdfLandscape": {"description": "Landscape PDF page-orientation option."},
"pdfMargins": "الهوامش", "@pdfMargins": {"description": "PDF export page-margin setting."},
"pdfMarginNarrow": "ضيقة", "@pdfMarginNarrow": {"description": "Narrow PDF page-margin option."},
"pdfMarginNormal": "عادية", "@pdfMarginNormal": {"description": "Normal PDF page-margin option."},
"pdfMarginWide": "عريضة", "@pdfMarginWide": {"description": "Wide PDF page-margin option."},
"pdfIncludePageNumbers": "تضمين أرقام الصفحات", "@pdfIncludePageNumbers": {"description": "Toggle for page numbers in an exported PDF."},
"export": "تصدير", "@export": {"description": "Button label that starts an export."},
"exportingPdf": "جارٍ تصدير PDF…", "@exportingPdf": {"description": "Progress dialog title while a PDF is being exported."},
"fileTypePdf": "مستند PDF", "@fileTypePdf": {"description": "File picker label for PDF documents."},
"pdfExported": "تم تصدير {fileName}.", "@pdfExported": {"description": "PDF export success message.", "placeholders": {"fileName": {"type": "String"}}},
"pdfExportedWithWarnings": "تم تصدير {fileName}. صور تعذر تضمينها: {count}.", "@pdfExportedWithWarnings": {"description": "PDF export success message when some images were omitted.", "placeholders": {"fileName": {"type": "String"}, "count": {"type": "int"}}},
"pdfExportUnavailable": "مكوّن تصدير PDF مفقود. أعد تثبيت BusyMark ثم حاول مجددًا.", "@pdfExportUnavailable": {"description": "Error shown when the bundled PDF compiler is unavailable."},
"pdfExportTimedOut": "استغرق تصدير PDF وقتًا طويلًا جدًا وتم إيقافه.", "@pdfExportTimedOut": {"description": "Error shown when PDF compilation times out."},
"pdfExportFailed": "تعذر على BusyMark تصدير هذا المستند بصيغة PDF.", "@pdfExportFailed": {"description": "Generic PDF export failure message."},
"shortcutExportPdfDescription": "تصدير مستند Markdown النشط بصيغة PDF.", "@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}

}
48 changes: 47 additions & 1 deletion lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -2379,6 +2379,52 @@
"errorWritersideRedirectInvalid": "Das ausgewählte Weiterleitungsziel ist nicht mehr gültig. Wählen Sie es erneut aus.",
"@errorWritersideRedirectInvalid": {"description": "Error shown when a safe-delete redirect target is stale or conflicts."},
"errorWritersideRollbackFailed": "Das Entfernen des Themas konnte nicht vollständig rückgängig gemacht werden. Prüfen Sie vor dem Fortfahren diese Pfade: {paths}",
"@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}}
"@errorWritersideRollbackFailed": {"description": "Error shown when safe-delete recovery leaves files requiring manual review.", "placeholders": {"paths": {"type": "String"}}},
"exportAsPdf": "Als PDF exportieren",
"@exportAsPdf": {"description": "Menu action and dialog title for exporting the active Markdown document as PDF."},
"pdfExportDescription": "Wählen Sie das Seitenlayout für eine professionelle, eigenständige PDF-Datei.",
"@pdfExportDescription": {"description": "Introductory text in the PDF export options dialog."},
"pdfRemoteImagesNote": "Remote Bilder werden beim Export nicht heruntergeladen. Lokale Bilder werden einbezogen, wenn sie verfügbar sind.",
"@pdfRemoteImagesNote": {"description": "Privacy note explaining image handling during PDF export."},
"pdfPageSize": "Seitengröße",
"@pdfPageSize": {"description": "PDF export page-size setting."},
"pdfPageSizeA4": "A4",
"@pdfPageSizeA4": {"description": "A4 PDF page-size option."},
"pdfPageSizeLetter": "US-Letter",
"@pdfPageSizeLetter": {"description": "US Letter PDF page-size option."},
"pdfOrientation": "Ausrichtung",
"@pdfOrientation": {"description": "PDF export page-orientation setting."},
"pdfPortrait": "Hochformat",
"@pdfPortrait": {"description": "Portrait PDF page-orientation option."},
"pdfLandscape": "Querformat",
"@pdfLandscape": {"description": "Landscape PDF page-orientation option."},
"pdfMargins": "Ränder",
"@pdfMargins": {"description": "PDF export page-margin setting."},
"pdfMarginNarrow": "Schmal",
"@pdfMarginNarrow": {"description": "Narrow PDF page-margin option."},
"pdfMarginNormal": "Standard",
"@pdfMarginNormal": {"description": "Normal PDF page-margin option."},
"pdfMarginWide": "Breit",
"@pdfMarginWide": {"description": "Wide PDF page-margin option."},
"pdfIncludePageNumbers": "Seitenzahlen einfügen",
"@pdfIncludePageNumbers": {"description": "Toggle for page numbers in an exported PDF."},
"export": "Exportieren",
"@export": {"description": "Button label that starts an export."},
"exportingPdf": "PDF wird exportiert…",
"@exportingPdf": {"description": "Progress dialog title while a PDF is being exported."},
"fileTypePdf": "PDF-Dokument",
"@fileTypePdf": {"description": "File picker label for PDF documents."},
"pdfExported": "{fileName} wurde exportiert.",
"@pdfExported": {"description": "PDF export success message.", "placeholders": {"fileName": {"type": "String"}}},
"pdfExportedWithWarnings": "{fileName} wurde exportiert. Nicht einbezogene Bilder: {count}.",
"@pdfExportedWithWarnings": {"description": "PDF export success message when some images were omitted.", "placeholders": {"fileName": {"type": "String"}, "count": {"type": "int"}}},
"pdfExportUnavailable": "Die PDF-Exportkomponente fehlt. Installieren Sie BusyMark neu und versuchen Sie es erneut.",
"@pdfExportUnavailable": {"description": "Error shown when the bundled PDF compiler is unavailable."},
"pdfExportTimedOut": "Der PDF-Export dauerte zu lange und wurde beendet.",
"@pdfExportTimedOut": {"description": "Error shown when PDF compilation times out."},
"pdfExportFailed": "BusyMark konnte dieses Dokument nicht als PDF exportieren.",
"@pdfExportFailed": {"description": "Generic PDF export failure message."},
"shortcutExportPdfDescription": "Das aktive Markdown-Dokument als PDF exportieren.",
"@shortcutExportPdfDescription": {"description": "Keyboard-shortcut description for PDF export."}

}
Loading
Loading