Export: plan (#24, #63, #303)
Export a note, a folder or the library as Markdown, HTML and PDF. The decisions are in the #24 thread (https://github.com/Nihmar/Niman/issues/24#issuecomment-5834935542). This file is the working plan: what is done, what is left, and in what order. Tick the steps off as they land.
Done (branches feat/24-export, then feat/63-pdf)
| Commit | What |
|---|---|
a8c0349b |
nimanInlineSyntaxes: ==mark== and <u>/<sup>/<sub> shared by the read view and the export |
85cfa667 |
MathSvg: a formula as inline SVG from katex_dart. The fonts are stripped from each drawing and declared once by the page, and only when a drawing writes text |
f3d52926 |
wikiDisplayText: one rule for what a wikilink shows |
aaa94d20 |
NoteHtml + htmlPage: a note as one self-contained HTML page (see below) |
f693ad30 |
ExportSources: the embed resolver shared with the read view; pictures gathered and read as data: URIs off the UI isolate |
2b5cc8ac |
A note as Markdown or one HTML page, behind the SaveExportFile seam; the ⋮ menu and the palette |
73e98c8a |
A folder or the library as one zip, streamed with progress and cancel; the tree menus and the library palette command |
How NoteHtml works.
- One
md.Documentparse over the whole note, with GFM plus the shared inline syntaxes. - Before the parse, the blocks the read view draws itself are replaced by tokens: fences (highlighted by
highlight), math blocks, raw HTML blocks (shown as source) and callouts (<details>when they fold). - Inside every other block, the constructs
ExtensionMaskerfinds become tokens too. - Tokens are put back after the parse. In an attribute they become the construct’s plain text.
- Pictures and link targets come in resolved, in
NoteHtmlSource. Building a page reads nothing, so it can run on any isolate.
Checked in a browser on the 10 KB and 50 KB fixtures, light and dark:
- Tables, task lists, stretchy delimiters and inline formulas render right.
- About 0.1–0.2 s per note. The KaTeX fonts, about 500 KB, are added once when needed.
1. Finish HTML (#24)
1.1 Resolving the sources
- Extract
NoteViewState._resolveEmbedinto a sharedresolveEmbedPath(target, notePath, root, linkSource), used by the read view and the export. Its lookup order stays as it is: library root, then the note’s folder, thenLinkSource.resolveWiki. ExportSourcescollects a note’s picture targets:- an embed’s (
ExtensionMaskerspans of kindembed); - an image’s (
mdimgsrc, as written and decoded).
Each target is resolved with the shared resolver. Only
EmbedView.imageExtensionscount as pictures.- an embed’s (
- Bytes become
data:URIs off the UI isolate (Isolate.run), with the MIME type from the extension. There is no size cap; a picture that cannot be read stays as written, and the skip is logged. - The page is built off the UI isolate too:
Isolate.run(() => htmlPage(NoteHtml(source).body() …)). - Tests: the resolver’s order, an unreadable picture, a note with none.
1.2 Single note
.md: the note’s bytes as they are on disk..html: the page above. Its title is the note’s display name (displayNameOf) and itslangis the app language.- Save through
FilePicker.saveFile(bytes: …), astheme_files.dartdoes. Put it behind an injectable typedef so widget tests can replace it. On Android this goes through SAF; on desktop, a save dialog.
1.3 Folder and library
- Zip of
.md: the subtree as it is on disk, attachments included, dot folders (.niman,.trash,.history) left out.- The zip is streamed to a file (
archive’sZipFileEncoder), never built in memory: a library can be a million notes. Keep the design rule “no O(n) in memory on a hot path”. - Destination: a folder picked with
FilePicker.getDirectoryPath, written withdart:io. Android can do this too, because the app already has all-files access (storage_access.dart); SAF would need the bytes up front.
- The zip is streamed to a file (
- Zip of HTML pages: every note becomes
path/Name.htmlat its own relative path.linksandimagesresolve inside the exported subtree: a target there becomes a relative URL, one that is not stays as the note wrote it. Changed from the plan’sLinkSource.resolveBatch: the index lives on the UI isolate, and resolving against the tree keeps the whole export on the export isolate and gives the behavior the export wants anyway.- Pictures are copied into the zip at their tree-relative paths, and
imagesmaps them to relative URLs instead ofdata:, so a picture used by many notes is stored once. - Non-note files, and notes that are not Markdown, are copied as they are.
- Work runs one entry at a time in a background isolate, with progress and cancellation. A cancelled export deletes its partial zip.
- Tests:
- zip contents and relative hrefs across folders;
- the heading anchor of
[[Note#Part]]; - a link to a note outside the exported folder, which becomes highlighted text;
- cancellation.
2. PDF (#63), on its own branch after #24
The PDF is the exported HTML page, printed. htmlPage already has
@media print rules. Add @page { size: A4; margin: 18mm; } and keep
break-inside: avoid on blocks.
2.1 Engines
- A
PdfPrinterinterface:Future<PdfOutcome> print(String htmlPath, String pdfPath).PdfOutcomeis one of: printed, no engine, or failed (with a message). - Windows: Edge. Find
msedge.exethrough theApp Pathsregistry key, thenProgram Files (x86)\Microsoft\Edge\Application. Run it with:--headless=new --disable-gpu --no-pdf-header-footer--print-to-pdf=<out> file:///<page>
Use a timeout that kills the engine, and check that the file exists and is not empty.
- Linux: a Chromium. Search
PATHfor the family’s names —chromium,google-chrome,microsoft-edge,brave,vivaldi,helium-browserand their variants — with the same flags. Every one of them is the same engine under a different name, so the search is a name list, not a per-browser branch: Helium’s wrapper was onPATHwith nothing matching it, and a machine that had an engine printed nothing and drew the note instead. - Android: WebView. A
MethodChannel(niman/pdf) inMainActivity:- An offscreen
WebViewloads the page withloadUrl(afile:URL). - On
onPageFinished, callcreatePrintDocumentAdapter. layoutandwritego to a file through a small helper in theandroid.printpackage, because its callback constructors are package-private.- A4 with the page’s own 18 mm margin (
PrintAttributes.Margins), and a 300 dpi resolution: the WebView’s adapter refuses a layout whose attributes do not name one — without it every Android PDF silently fell back to a picture.
A
cancelcall destroys the view and answers a waiting print, so a cancelled export leaves no busy bridge. No on-device test yet. - An offscreen
- Tests: engine discovery with a fake
PATHand a fake file system; the command line that is built; the outcomes.
2.2 Raster fallback (Linux without Chromium)
- Lay out
MarkdownExportViewoffscreen at the page’s content width. The pipeline (RenderView,BuildOwner,PipelineOwner) is left to the caller bymarkdown_export.dart, so it lives here. - The page is a slice of the one tall layout, and the slice ends where the layout has a gap: the break moves up to the last offset between two lines —
pdf_breaks.dartasks the render tree for its lines (a paragraph’sgetBoxesForSelection, one span per line) and every picture, and takes the page’s edge back to the last clear offset. So a page never cuts a line in half; a line or a picture taller than the page has nowhere to break and is cut. A page is drawn from the top of its content box, so a short page still starts where the others do. (The planned block-level page breaks were not built: the printed browser path owns real pagination, and this is the fallback for a machine without one.) - The note is recorded once and sliced per page; pages go into a
PdfWriterone at a time, so a novel does not hold every page’s pixels at once. - Every picture the note shows is decoded and drawn in place — the fallback hands the page its pictures, or the note would silently lose them.
- The printed page points at its pictures with
file:URLs, where the exported HTML must carrydata:URIs: the engine fetches them itself, and embedding a library’s photos built pages of hundreds of megabytes — the Android bridge read one into its own heap until it OOM’d. - The export says it is running — the engine printing, then the pages drawn — and can be cancelled from the dialog; the fallback checks between pages, and Android’s bridge stops a print in flight.
- The machine is asked before the note is read: no engine to print with means a picture of the pages, so the export shows a pre-flight dialog (Cancel / Export anyway) and stops there if the user wants; the note is read and the dialog closed behind the progress one, so nothing is done before the answer. Afterwards the app says what happened as well: the PDF is a picture of the pages, and a browser engine on the machine gives selectable text.
- Tests: the page count over a known note; a break never falls inside a line of a real layout; the paper is A4 in points; a picture handed in is drawn; the writer round-trips its image streams.
2.3 What gets exported as PDF
- A note gives one
.pdf. - A folder gives a zip with one
.pdfper note, its pictures embedded. A combined PDF is an open question (below).
3. Hooking up the UI
Every entry exists on Android, Linux and Windows.
- The note’s ⋮ menu.
- Add
NoteMenuAction.exportinui/note_menu.dart, insideif (textNote), next to Format and History; the key isnote-menu-export. - It is handled in
shell.dart’s_noteMenu(). - It opens an Export sheet (phone) or dialog (desktop) with the formats Markdown / HTML / PDF. (PDF in #63.)
- Add
- The tree row menu (
ui/shell_row_menu.dart,rowMenuGroups, the file group):- a note gets “Export…”, the same chooser;
- a folder gets “Export folder…”, with Markdown zip / HTML zip. (PDF in #63.)
Dispatch goes in
ui/shell_row_actions.dartrun. - The tree’s background menu (
showTreeBackgroundMenuAt): “Export library…”, the folder chooser for the root. - Command palette. Add
AppCommand.exportNoteandAppCommand.exportLibraryinui/app_shortcuts.dart, with labels.paletteGroup: note / library.paletteAsks: true.commandNeeds:{CommandNeed.textNote}for the note.- Handlers in
shell.dart_allCommandHandlers(). Key maps and pins pick them up fromAppCommand.values.
- Progress. A single note is quick: a snackbar at the end. A folder gets a progress dialog with a cancel button.
- When it is done. A snackbar with where the file went. On desktop it also offers Show in folder (
file_tree_context.dart). - Strings in every locale (
ui/strings/*.dart). - Docs:
docs/user/: a newexport.md, linked fromorganization.md;platforms.md: the PDF engine per platform and the Linux fallback; (with #63)settings.md, if a setting appears; (no setting)CHANGELOG.mdat the release. (shipped in 0.1.0)
- Widget tests: the menu entries, the chooser, the save call through the fake picker, and cancellation.
4. Order of work
feat/24-export:- 1.1 sources;
- 1.2 single note;
- the UI entries for a single note (part of 3);
- 1.3 folders and the library, with their UI entries;
- docs;
- PR
Closes #24.
feat/63-pdf:- 2.1 engines, Windows and Linux first, then Android;
- 2.2 fallback;
- 2.3;
- the PDF format in the choosers;
- docs;
- PR
Closes #63.
Commit each step as it lands. Run analyze and the targeted tests on each commit, and the integration tests before the PR.
5. EPUB (#303), after the pages
A note is a one-chapter book; a folder (or the library) is one book
with a chapter per note. The body is the same exported page (NoteHtml,
MathSvg, the tree’s link and picture resolution) in the XHTML an EPUB
demands; the container is mimetype (stored, first),
META-INF/container.xml, OEBPS/content.opf, OEBPS/nav.xhtml,
style.css and the pictures under OEBPS/images/.
EpubBook: chapters and pictures streamed into the container, the package and the nav written last; a picture copied once whatever the chapter that shows it, the maths fonts declared once for the book.- One note → one
.epub, its pictures inside. - Folder/library → one
.epub, a chapter per note, links between chapters internal, one nav entry per chapter. Its frontmatter isindex.md’s at the folder’s root, when there is one. - The EPUB format in both choosers, on every platform,
.epubfile names, strings in every locale. - Tests: the container’s shape and first-stored
mimetype, every XHTML and the OPF parsed as XML, math as inline SVG, pictures inside, a shared picture copied once, links between chapters. - Read back by hand in Calibre / Apple Books / KOReader and in the app’s own pane (#280).
- Cover and metadata from the frontmatter: the note’s own, or
index.md’s at the exported folder’s root.cover:,title,author,language/lang,description,publisher,published(notdate:, which is the note’s own),tags(→dc:subject),series+series_index(EPUB 3belongs-to-collection),rights,identifier/isbn. The cover is acover.xhtmlpage first in the spine, the EPUB 3cover-imageproperty, and the EPUB 2meta name="cover"for older readers. The Markdown cheatsheet documents the keys, in every language. - A folder with no
index.mdat its root, or one whoseindex.mdhas no frontmatter, asks before the export starts — the book would carry the folder’s name and nothing else — with Cancel (stop and write the note) and Export anyway; the export logs the missing source, and acover:that does not resolve too (E3). - Nav folded by folder (a flat, tree-ordered list for now).
Open questions
- A folder as one combined PDF (notes in tree order, a page break between them) instead of a zip of PDFs.
- Very large pictures inline in a single-note page: embed them all, or link them above some size.
- A library-wide HTML zip on a phone: it streams, so memory is bounded, but the time for a million notes calls for a progress estimate before it starts.