From 1110f832477281072e6179bf646dd3adaed87578 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 12 Sep 2026 18:34:58 -0700 Subject: [PATCH 01/19] Bump version to v0.4.1 --- lib/src/app/app_metadata.dart | 2 +- linux/io.busystack.busymark.metainfo.xml | 1 + pubspec.yaml | 2 +- snap/snapcraft.yaml | 2 +- test/src/visualization_packaging_audit_test.dart | 10 +++++----- 5 files changed, 9 insertions(+), 8 deletions(-) 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/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/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/src/visualization_packaging_audit_test.dart b/test/src/visualization_packaging_audit_test.dart index d4fed33b..0e18367a 100644 --- a/test/src/visualization_packaging_audit_test.dart +++ b/test/src/visualization_packaging_audit_test.dart @@ -29,10 +29,10 @@ void main() { 'linux/io.busystack.busymark.metainfo.xml', ).readAsStringSync(); - expect(pubspec, contains(RegExp(r'^version: 0\.4\.0$', multiLine: true))); + expect(pubspec, contains(RegExp(r'^version: 0\.4\.1$', multiLine: true))); expect( snapcraft, - contains(RegExp(r'^version: "0\.4\.0"$', multiLine: true)), + contains(RegExp(r'^version: "0\.4\.1"$', multiLine: true)), ); expect(snapcraft, contains(RegExp(r'^grade: stable$', multiLine: true))); expect( @@ -57,9 +57,9 @@ void main() { ), ), ); - expect(metainfo, contains(' Date: Sat, 12 Sep 2026 18:35:06 -0700 Subject: [PATCH 02/19] Restore Export to the main menu --- docs/html-export.md | 7 +-- docs/pdf-export.md | 11 ++--- docs/writerside-pdf-export.md | 7 +-- lib/src/app/busymark_app.dart | 5 +++ lib/src/app/busymark_main_menu.dart | 16 ++++++- .../platform/header_bar_configuration.dart | 23 ++++++++++ .../platform/linux_header_bar_service.dart | 8 ++++ .../presentation/settings_screen.dart | 3 ++ .../presentation/welcome_screen.dart | 3 ++ .../presentation/workspace_screen.dart | 7 +++ linux/runner/my_application.cc | 45 +++++++++++++++++++ test/src/app_smoke_test.dart | 34 +++++++++++++- test/src/header_bar_configuration_test.dart | 14 ++++-- test/src/html_export_ui_test.dart | 25 ++++++++--- test/src/linux_header_bar_service_test.dart | 3 ++ test/src/native_headerbar_audit_test.dart | 18 ++++++-- 16 files changed, 203 insertions(+), 26 deletions(-) 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..089877d7 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. 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/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/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..28d018c9 100644 --- a/lib/src/workspace/presentation/workspace_screen.dart +++ b/lib/src/workspace/presentation/workspace_screen.dart @@ -779,6 +779,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 +974,7 @@ class WorkspaceScreen extends ConsumerWidget { }, ), BusyMarkMainMenuButton( + canExport: canExportPdf || canExportHtml, onSelected: (action) => _handleMainMenuAction(context, ref, action), ), @@ -1109,6 +1112,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 +1163,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: 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/test/src/app_smoke_test.dart b/test/src/app_smoke_test.dart index 81bef8b1..d3f618d8 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) => 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_ui_test.dart b/test/src/html_export_ui_test.dart index 2d6d3967..945e9c7e 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( 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/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"'))); From 914f4e2718537bb31062da64786473a867749631 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 12 Sep 2026 19:17:34 -0700 Subject: [PATCH 03/19] Add support for anchors in list items and handle inline content in groups. Normalize line endings and improve list and link attributes handling in HTML export. Refactor PDF export service for improved topic handling and add anchor mapping helper in Markdown export. --- assets/export/markdown.typ | 3 + lib/src/export/html_document_writer.dart | 75 +++-- lib/src/export/html_export_links.dart | 29 +- lib/src/export/markdown_export_mapper.dart | 23 +- .../export/writerside_pdf_export_service.dart | 71 +++-- test/src/html_export_options_test.dart | 80 ++++++ test/src/html_publication_test.dart | 56 ++++ test/src/html_rich_export_test.dart | 43 +++ test/src/markdown_export_mapper_test.dart | 50 ++++ test/src/writerside_pdf_export_test.dart | 256 ++++++++++++++++++ test/support/headless_chrome.dart | 115 ++++++++ 11 files changed, 733 insertions(+), 68 deletions(-) create mode 100644 test/support/headless_chrome.dart diff --git a/assets/export/markdown.typ b/assets/export/markdown.typ index 8921201d..9af97b2d 100644 --- a/assets/export/markdown.typ +++ b/assets/export/markdown.typ @@ -223,6 +223,8 @@ #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 { "☐" })) @@ -424,6 +426,7 @@ raw(value-or(block-data, "text", ""), block: true), ) } else if kind == "group" { + if inlines.len() > 0 { par(render-inlines(inlines)) } for child in children { render-block(child) } } else { render-inlines(inlines) diff --git a/lib/src/export/html_document_writer.dart b/lib/src/export/html_document_writer.dart index 897490e0..2514d76d 100644 --- a/lib/src/export/html_document_writer.dart +++ b/lib/src/export/html_document_writer.dart @@ -145,8 +145,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. @@ -348,6 +352,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,7 +369,13 @@ class HtmlDocumentWriter { final b = blocks[i]; if (_isList(b)) { final ordered = _ordered(b); + final listType = _listType(b); + 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['marker'] ?? '').replaceAll(RegExp(r'[^0-9-]'), ''), @@ -372,7 +384,9 @@ 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['markerHidden'] == 'true') == markerHidden) { final item = blocks[i]; final li = _el('li'); _attributes(li, item.attributes); @@ -489,23 +503,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 +593,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 +878,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 +1059,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..cf586386 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,7 @@ class HtmlExportLinks { return uri.toString(); } - Future resolve( + Future resolve( String? value, HtmlPage page, Map attributes, { @@ -62,7 +70,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 +85,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 +139,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 +164,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/markdown_export_mapper.dart b/lib/src/export/markdown_export_mapper.dart index 184bec3a..07ef0e55 100644 --- a/lib/src/export/markdown_export_mapper.dart +++ b/lib/src/export/markdown_export_mapper.dart @@ -49,7 +49,7 @@ class MarkdownExportMapper { currentVariant = variant; final override = blockOverrides[block.id]; if (override != null) { - result.add(override); + result.add(_withAnchor(override, block)); index++; continue; } @@ -86,15 +86,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 +97,16 @@ 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, + }, + ); + MarkdownExportBlock _mapListItem( BusyBlock block, Map blockOverrides, @@ -115,6 +117,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/writerside_pdf_export_service.dart b/lib/src/export/writerside_pdf_export_service.dart index c8692d9a..5e7f4248 100644 --- a/lib/src/export/writerside_pdf_export_service.dart +++ b/lib/src/export/writerside_pdf_export_service.dart @@ -211,54 +211,69 @@ 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) { + 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}.', + ); + } + if (startTopic != null && + node.topicReference != null && + owner.topicByReference(node.topicReference!)?.filePath == + startTopic.filePath) { + startHidden = (startHidden ?? true) && node.hidden; + } if (!node.workInProgress) { - addReference(node.topicReference, hidden: node.hidden); + 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 @@ -271,7 +286,7 @@ class WritersidePdfExportService { } if (selected.isEmpty) { for (final reference in instance.topicFileSet) { - addReference(reference, hidden: false); + addReference(reference, module, hidden: false); } } if (selected.length > maximumTopics) { @@ -320,7 +335,7 @@ class WritersidePdfExportService { final resolved = documentResolver.resolve( parsedTopic.document, WritersideResolveContext( - module: module, + module: selection.module, topic: parsedTopic, instance: instance, modulesByOrigin: modulesByOrigin, @@ -347,7 +362,7 @@ class WritersidePdfExportService { ); } final assets = await _resolveBusyAssets( - module, + selection.module, parsedTopic, rendered, modulesByOrigin, 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_publication_test.dart b/test/src/html_publication_test.dart index 9c3e269b..153b71f5 100644 --- a/test/src/html_publication_test.dart +++ b/test/src/html_publication_test.dart @@ -49,6 +49,62 @@ 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 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/markdown_export_mapper_test.dart b/test/src/markdown_export_mapper_test.dart index a06cc718..e36ca78e 100644 --- a/test/src/markdown_export_mapper_test.dart +++ b/test/src/markdown_export_mapper_test.dart @@ -4,6 +4,8 @@ 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:flutter_test/flutter_test.dart'; @@ -11,6 +13,54 @@ void main() { const parser = MarkdownParser(); const mapper = MarkdownExportMapper(); + 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/writerside_pdf_export_test.dart b/test/src/writerside_pdf_export_test.dart index 6aa9fd48..de018c43 100644 --- a/test/src/writerside_pdf_export_test.dart +++ b/test/src/writerside_pdf_export_test.dart @@ -5,17 +5,240 @@ 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_export_mapper.dart'; import 'package:busymark/src/export/markdown_export_document.dart'; +import 'package:busymark/src/export/markdown_visualization_export.dart'; import 'package:busymark/src/export/typst_compiler.dart'; import 'package:busymark/src/export/typst_payload_builder.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/markdown_model.dart'; import 'package:busymark/src/markdown/busymark_document.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:path/path.dart' as p; void main() { final compiler = _bundledTypstCompiler(); + for (final origin in ['unknown', 'shared-pdf']) { + test( + 'unresolved TOC origin or shared topic fails explicitly ($origin)', + () async { + final fixture = await _WritersideFixture.create(); + addTearDown(fixture.dispose); + await File(p.join(fixture.module.path, 'guide.tree')).writeAsString( + '' + '' + '' + '', + ); + final exporter = _RecordingMarkdownExporter(); + await expectLater( + WritersidePdfExportService(markdownExporter: exporter).export( + WritersidePdfExportRequest( + moduleRoot: fixture.module.path, + projectRoot: fixture.root.path, + instanceId: 'guide', + destinationPath: p.join(fixture.root.path, 'invalid.pdf'), + overwrite: false, + ), + ), + throwsA( + isA() + .having( + (e) => e.code, + 'code', + WritersidePdfFailureCode.invalidRequest, + ) + .having( + (e) => e.detail, + 'detail', + contains( + origin == 'unknown' + ? 'Unknown TOC module origin' + : 'Missing TOC topic', + ), + ), + ), + ); + expect(exporter.request, isNull); + }, + ); + } + for (final collision in [false, true]) { + test( + 'PDF selects shared TOC topics with filename collision=$collision', + () async { + final fixture = await _WritersideFixture.create(); + addTearDown(fixture.dispose); + final shared = p.join(fixture.root.path, 'Shared'); + await File(p.join(shared, 'writerside.cfg')).writeAsString( + '', + ); + await File(p.join(shared, 'v.list')).writeAsString( + '', + ); + await File(p.join(shared, 'topics/library.topic')).writeAsString( + '

    %product%

    ', + ); + if (collision) { + await File( + p.join(fixture.module.path, 'topics/library.topic'), + ).writeAsString( + '

    Unrelated local content

    ', + ); + } + await File(p.join(fixture.module.path, 'guide.tree')).writeAsString( + '' + '' + '' + '', + ); + final exporter = _RecordingMarkdownExporter(); + await WritersidePdfExportService(markdownExporter: exporter).export( + WritersidePdfExportRequest( + moduleRoot: fixture.module.path, + projectRoot: fixture.root.path, + instanceId: 'guide', + destinationPath: p.join(fixture.root.path, 'shared.pdf'), + overwrite: false, + ), + ); + final blocks = _allBlocks(exporter.request!.document!.blocks).toList(); + final text = blocks.map((b) => b.plainText).join('\n'); + expect(text, contains('Shared library')); + expect(text, contains('Shared product')); + final headings = blocks + .where((b) => b.kind == BusyBlockKind.heading) + .map((b) => b.plainText) + .toList(); + expect(headings, isNot(contains('Local library'))); + expect(text, isNot(contains('Unrelated local content'))); + expect( + blocks + .expand((b) => _allInlines(b.inlines)) + .where((i) => i.kind == BusyInlineKind.image) + .map((i) => i.destination), + contains(Uri.file(p.join(shared, 'images/shared.png')).toString()), + ); + }, + ); + } + + for (final rendered in [true, false]) { + test( + 'final PDF retains procedure titles, definition terms and linked targets (diagram rendered=$rendered)', + () async { + final fixture = await _WritersideFixture.create(); + addTearDown(fixture.dispose); + await File( + p.join(fixture.module.path, 'topics/intro.md'), + ).writeAsString('# Introduction'); + await File( + p.join(fixture.module.path, 'topics/advanced.topic'), + ).writeAsString(''' + +

    Install the package

    +

    Credential explanation

    +
  • Listed target

  • + graph LR; A-->B +

    Go to step Go to item Go to diagram

    +
    '''); + final recording = _RecordingMarkdownExporter(); + await WritersidePdfExportService(markdownExporter: recording).export( + WritersidePdfExportRequest( + moduleRoot: fixture.module.path, + projectRoot: fixture.root.path, + instanceId: 'guide', + destinationPath: p.join(fixture.root.path, 'recording.pdf'), + overwrite: false, + ), + ); + final coordinator = VisualizationCoordinator( + renderers: [_PdfDiagramRenderer(rendered: rendered)], + cache: VisualizationCache( + diskRoot: Directory(p.join(fixture.root.path, 'cache')), + ), + ); + addTearDown(coordinator.dispose); + final busyDocument = recording.request!.document!; + final preparation = + await MarkdownVisualizationExportRenderer( + coordinator: coordinator, + ).prepare( + document: busyDocument, + exportRoot: fixture.root, + documentPath: busyDocument.filePath, + workspaceRoot: fixture.root.path, + cancellationToken: MarkdownPdfCancellationToken(), + ); + expect(preparation.blockOverrides, hasLength(rendered ? 1 : 0)); + final document = const MarkdownExportMapper().map( + busyDocument, + blockOverrides: preparation.blockOverrides, + ); + final blocks = _allExportBlocks(document.blocks).toList(); + final anchors = blocks + .map((b) => b.attributes['anchor']) + .whereType() + .toSet(); + final links = blocks + .expand((b) => b.inlines) + .where((i) => i.kind == MarkdownExportInlineKind.link) + .toList(); + expect(links, hasLength(3)); + for (final link in links) { + expect( + anchors, + contains(link.destination!.substring(1)), + reason: link.text, + ); + } + final payload = const TypstPayloadBuilder().build( + document: document, + options: const PdfExportOptions(), + assets: {}, + ); + await File( + p.join(fixture.root.path, 'document.json'), + ).writeAsString(jsonEncode(payload)); + await File( + 'assets/export/markdown.typ', + ).copy(p.join(fixture.root.path, 'main.typ')); + final pdf = p.join(fixture.root.path, 'regression.pdf'); + final compiled = await Process.run(compiler!, [ + 'compile', + p.join(fixture.root.path, 'main.typ'), + pdf, + ]); + expect(compiled.exitCode, 0, reason: '${compiled.stderr}'); + final extracted = await Process.run('/usr/bin/pdftotext', [pdf, '-']); + expect(extracted.exitCode, 0, reason: '${extracted.stderr}'); + final text = extracted.stdout as String; + for (final content in [ + 'Installation', + 'Install the package', + 'API key', + 'Credential explanation', + 'Listed target', + 'Go to diagram', + ]) { + expect(text, contains(content)); + } + expect( + text.indexOf('Installation'), + lessThan(text.indexOf('Install the package')), + ); + expect( + text.indexOf('API key'), + lessThan(text.indexOf('Credential explanation')), + ); + if (!rendered) expect(text, contains('graph LR; A-->B')); + }, + skip: compiler == null || !File('/usr/bin/pdftotext').existsSync(), + ); + } test( 'bundled Typst compiles semantic tables, anchors and glossary footnotes', () async { @@ -692,6 +915,39 @@ Iterable _allInlines(Iterable inlines) sync* { } } +class _PdfDiagramRenderer implements VisualizationRenderer { + const _PdfDiagramRenderer({required this.rendered}); + final bool rendered; + + @override + Set get supportedKinds => { + VisualizationRendererKind.mermaid, + }; + + @override + Future prepare( + VisualizationRenderRequest request, + VisualizationCancellationToken token, + ) async => request; + + @override + Future render( + VisualizationRenderRequest request, + VisualizationCancellationToken token, + ) async => rendered + ? const SvgVisualizationResult( + svg: + '', + width: 40, + height: 20, + ) + : const FailedVisualizationResult( + code: 'test.failed', + message: 'Use source fallback', + retryable: false, + ); +} + class _WritersideFixture { const _WritersideFixture({required this.root, required this.module}); diff --git a/test/support/headless_chrome.dart b/test/support/headless_chrome.dart new file mode 100644 index 00000000..d748206c --- /dev/null +++ b/test/support/headless_chrome.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// Optional browser integration, using the installed browser's DevTools API. +final String? headlessChromePath = [ + Platform.environment['BUSYMARK_CHROME_PATH'], + '/usr/bin/google-chrome', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', +].whereType().where((path) => File(path).existsSync()).firstOrNull; + +Future evaluateInChrome(String htmlPath, String expression) => + HttpOverrides.runWithHttpOverrides( + () => _evaluateInChrome(htmlPath, expression), + _BrowserHttpOverrides(), + ); + +class _BrowserHttpOverrides extends HttpOverrides {} + +Future _evaluateInChrome(String htmlPath, String expression) async { + final profile = await Directory.systemTemp.createTemp('html-csp-browser-'); + Process? browser; + WebSocket? socket; + Uri? browserEndpoint; + final client = HttpClient(); + try { + final endpoint = Completer(); + browser = await Process.start(headlessChromePath!, [ + '--headless=new', + '--no-first-run', + '--disable-background-networking', + '--disable-component-update', + '--remote-debugging-port=0', + '--user-data-dir=${profile.path}', + '--proxy-server=http://127.0.0.1:9', + '--proxy-bypass-list=127.0.0.1;localhost', + Uri.file(htmlPath).toString(), + ]); + unawaited(browser.stdout.drain()); + browser.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) { + const prefix = 'DevTools listening on '; + if (line.startsWith(prefix) && !endpoint.isCompleted) { + endpoint.complete(Uri.parse(line.substring(prefix.length))); + } + }); + final uri = await endpoint.future.timeout(const Duration(seconds: 15)); + browserEndpoint = uri; + final request = await client.getUrl( + Uri.http('${uri.host}:${uri.port}', '/json/list'), + ); + final response = await request.close(); + final targets = + jsonDecode(await response.transform(utf8.decoder).join()) as List; + final page = targets.cast>().firstWhere( + (t) => t['type'] == 'page', + ); + socket = await WebSocket.connect(page['webSocketDebuggerUrl'] as String); + final messages = StreamIterator(socket); + socket.add( + jsonEncode({ + 'id': 1, + 'method': 'Runtime.evaluate', + 'params': { + 'expression': + '''(async () => { + if (document.readyState !== 'complete') { + await new Promise(resolve => addEventListener('load', resolve, {once:true})); + } + return $expression; + })()''', + 'awaitPromise': true, + 'returnByValue': true, + }, + }), + ); + Future readResult() async { + while (await messages.moveNext()) { + final message = jsonDecode(messages.current as String) as Map; + if (message['id'] != 1) continue; + if (message['error'] != null || + message['result']['exceptionDetails'] != null) { + throw StateError('Browser evaluation failed: $message'); + } + return message['result']['result']['value']; + } + throw StateError('Browser closed before evaluating styles.'); + } + + return await readResult().timeout(const Duration(seconds: 15)); + } finally { + await socket?.close(); + client.close(force: true); + if (browser != null) { + if (browserEndpoint != null) { + final control = await WebSocket.connect(browserEndpoint.toString()); + control.add(jsonEncode({'id': 2, 'method': 'Browser.close'})); + await control.drain().timeout(const Duration(seconds: 5)); + } else { + browser.kill(); + } + await browser.exitCode.timeout( + const Duration(seconds: 5), + onTimeout: () { + browser!.kill(ProcessSignal.sigkill); + return browser.exitCode; + }, + ); + } + await profile.delete(recursive: true); + } +} From e064f3632d626c3f4b3386a5ae1989784fc33a75 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 12 Sep 2026 20:06:47 -0700 Subject: [PATCH 04/19] Add `listId` support for lists to enhance export capabilities. Handle `listId` and `listOrdinal` attributes in HTML and Markdown export for improved list consistency. --- lib/src/export/html_document_writer.dart | 3 + lib/src/export/markdown_export_mapper.dart | 10 +++- .../writerside_document_renderer.dart | 7 +++ test/fixtures/writerside/list_numbering.topic | 18 ++++++ test/src/html_publication_test.dart | 38 +++++++++++++ test/src/markdown_export_mapper_test.dart | 32 +++++++++++ test/src/writerside_document_test.dart | 27 +++++++++ test/src/writerside_pdf_export_test.dart | 55 +++++++++++++++++++ 8 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/writerside/list_numbering.topic diff --git a/lib/src/export/html_document_writer.dart b/lib/src/export/html_document_writer.dart index 2514d76d..f4def6d3 100644 --- a/lib/src/export/html_document_writer.dart +++ b/lib/src/export/html_document_writer.dart @@ -370,6 +370,7 @@ class HtmlDocumentWriter { 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'; @@ -377,6 +378,7 @@ class HtmlDocumentWriter { _style(list, 'list-style-type:none;'); } final start = + int.tryParse(b.attributes['listOrdinal'] ?? '') ?? int.tryParse( (b.attributes['marker'] ?? '').replaceAll(RegExp(r'[^0-9-]'), ''), ) ?? @@ -386,6 +388,7 @@ class HtmlDocumentWriter { _isList(blocks[i]) && _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'); diff --git a/lib/src/export/markdown_export_mapper.dart b/lib/src/export/markdown_export_mapper.dart index 07ef0e55..0896bd41 100644 --- a/lib/src/export/markdown_export_mapper.dart +++ b/lib/src/export/markdown_export_mapper.dart @@ -57,15 +57,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)); diff --git a/lib/src/writerside/writerside_document_renderer.dart b/lib/src/writerside/writerside_document_renderer.dart index 4e600266..ee1a2af3 100644 --- a/lib/src/writerside/writerside_document_renderer.dart +++ b/lib/src/writerside/writerside_document_renderer.dart @@ -944,6 +944,7 @@ class WritersideDocumentRenderer { }) { final type = list.attributes['type']?.trim().toLowerCase() ?? 'bullet'; final start = int.tryParse(list.attributes['start'] ?? '') ?? 1; + final listId = '${list.span.filePath}:${_nodeId(list)}'; var itemNumber = start; final result = []; for (final child in list.children) { @@ -953,6 +954,7 @@ class WritersideDocumentRenderer { _listItemBlock( child, listType: type, + listId: listId, itemNumber: itemNumber++, headingLevel: headingLevel, ), @@ -969,6 +971,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 +995,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', }, 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/src/html_publication_test.dart b/test/src/html_publication_test.dart index 153b71f5..45d8c801 100644 --- a/test/src/html_publication_test.dart +++ b/test/src/html_publication_test.dart @@ -75,6 +75,44 @@ void main() { 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('Writerside list styles stay distinct across consecutive lists', () async { await module( 'module', diff --git a/test/src/markdown_export_mapper_test.dart b/test/src/markdown_export_mapper_test.dart index e36ca78e..def2342d 100644 --- a/test/src/markdown_export_mapper_test.dart +++ b/test/src/markdown_export_mapper_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:busymark/src/export/markdown_export_document.dart'; import 'package:busymark/src/export/markdown_export_mapper.dart'; @@ -7,12 +8,43 @@ 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', diff --git a/test/src/writerside_document_test.dart b/test/src/writerside_document_test.dart index ab77d45e..d1fe2691 100644 --- a/test/src/writerside_document_test.dart +++ b/test/src/writerside_document_test.dart @@ -277,6 +277,33 @@ void main() { expect(blocks[5].attributes['markerHidden'], 'true'); }); + test('XML lists retain numeric ordinals and their container identity', () { + final document = const WritersideDocumentParser().parseXml( + filePath: '/tmp/lists.topic', + source: File( + 'test/fixtures/writerside/list_numbering.topic', + ).readAsStringSync(), + ); + final blocks = const WritersideDocumentRenderer() + .toBusyDocument(document) + .blocks; + expect(blocks.take(6).map((b) => b.attributes['listOrdinal']), [ + '3', + '4', + '1', + '2', + '1', + '2', + ]); + expect(blocks.take(2).map((b) => b.attributes['marker']), ['c.', 'd.']); + final ids = blocks.take(6).map((b) => b.attributes['listId']).toList(); + expect(ids, everyElement(isNotNull)); + expect(ids[0], ids[1]); + expect(ids[2], ids[3]); + expect(ids[4], ids[5]); + expect({ids[0], ids[2], ids[4]}, hasLength(3)); + }); + test('workspace exposes resolver errors with exact source spans', () async { final fixture = await _ResolvedFixture.create(); addTearDown(fixture.dispose); diff --git a/test/src/writerside_pdf_export_test.dart b/test/src/writerside_pdf_export_test.dart index de018c43..5aa98af4 100644 --- a/test/src/writerside_pdf_export_test.dart +++ b/test/src/writerside_pdf_export_test.dart @@ -21,6 +21,61 @@ import 'package:path/path.dart' as p; void main() { final compiler = _bundledTypstCompiler(); + test( + 'final PDF keeps alphabetic starts, separate decimal lists and procedure numbering', + () async { + final fixture = await _WritersideFixture.create(); + addTearDown(fixture.dispose); + await File( + p.join(fixture.module.path, 'topics/intro.md'), + ).writeAsString('# Introduction'); + await File( + p.join(fixture.module.path, 'topics/advanced.topic'), + ).writeAsString( + (await File( + 'test/fixtures/writerside/list_numbering.topic', + ).readAsString()).replaceFirst('id="list-numbering"', 'id="advanced"'), + ); + final destination = p.join(fixture.root.path, 'lists.pdf'); + await WritersidePdfExportService( + markdownExporter: MarkdownPdfExportService( + compilerLocator: TypstCompilerLocator( + environment: {'BUSYMARK_TYPST_PATH': compiler!}, + ), + templateLoader: () => + File('assets/export/markdown.typ').readAsString(), + ), + ).export( + WritersidePdfExportRequest( + moduleRoot: fixture.module.path, + projectRoot: fixture.root.path, + instanceId: 'guide', + destinationPath: destination, + overwrite: false, + ), + ); + 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 (marker, content) in [ + ('c', 'Charlie'), + ('d', 'Delta'), + ('1', 'First one'), + ('2', 'First two'), + ('1', 'Second one'), + ('2', 'Second two'), + ('1', 'Step one'), + ('2', 'Step two'), + ]) { + expect(text, matches(RegExp('\\b$marker\\.\\s+$content\\b'))); + } + }, + skip: compiler == null || !File('/usr/bin/pdftotext').existsSync(), + ); for (final origin in ['unknown', 'shared-pdf']) { test( 'unresolved TOC origin or shared topic fails explicitly ($origin)', From f94d0461605c3724ec1bb7937dec8be339226149 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 12 Sep 2026 20:18:14 -0700 Subject: [PATCH 05/19] Add `toBusyDocument` method and improve list ID handling for consistent rendering --- .../writerside_document_renderer.dart | 20 ++++++- .../repeated_list_include/guide.tree | 3 ++ .../repeated_list_include/images/.gitkeep | 1 + .../topics/library.topic | 8 +++ .../repeated_list_include/topics/start.topic | 4 ++ .../repeated_list_include/writerside.cfg | 4 ++ test/src/html_publication_test.dart | 23 ++++++++ test/src/writerside_document_test.dart | 54 +++++++++++++++++++ test/src/writerside_pdf_export_test.dart | 50 +++++++++++++++++ 9 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/writerside/repeated_list_include/guide.tree create mode 100644 test/fixtures/writerside/repeated_list_include/images/.gitkeep create mode 100644 test/fixtures/writerside/repeated_list_include/topics/library.topic create mode 100644 test/fixtures/writerside/repeated_list_include/topics/start.topic create mode 100644 test/fixtures/writerside/repeated_list_include/writerside.cfg diff --git a/lib/src/writerside/writerside_document_renderer.dart b/lib/src/writerside/writerside_document_renderer.dart index ee1a2af3..e993885d 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,7 +960,9 @@ class WritersideDocumentRenderer { }) { final type = list.attributes['type']?.trim().toLowerCase() ?? 'bullet'; final start = int.tryParse(list.attributes['start'] ?? '') ?? 1; - final listId = '${list.span.filePath}:${_nodeId(list)}'; + // 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) { 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/html_publication_test.dart b/test/src/html_publication_test.dart index 45d8c801..8c6ee2f1 100644 --- a/test/src/html_publication_test.dart +++ b/test/src/html_publication_test.dart @@ -113,6 +113,29 @@ void main() { }, ); + 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', diff --git a/test/src/writerside_document_test.dart b/test/src/writerside_document_test.dart index d1fe2691..0842391d 100644 --- a/test/src/writerside_document_test.dart +++ b/test/src/writerside_document_test.dart @@ -304,6 +304,60 @@ void main() { expect({ids[0], ids[2], ids[4]}, hasLength(3)); }); + test( + 'repeated list includes have distinct occurrence IDs and original source spans', + () async { + final fixture = Directory( + 'test/fixtures/writerside/repeated_list_include', + ).absolute; + final module = await const WritersideModuleService().load(fixture.path); + final topic = module.topicByReference('start.topic')!; + final resolved = const WritersideDocumentResolver().resolve( + topic.document, + WritersideResolveContext( + module: module, + topic: topic, + instance: module.instances.single, + ), + ); + expect(resolved.diagnostics, isEmpty); + final originalItems = module + .topicByReference('library.topic')! + .document + .elements + .where((element) => element.name == 'li') + .toList(); + const renderer = WritersideDocumentRenderer(); + final rendered = renderer.toBusyDocument(resolved.document); + expect(rendered.blocks, hasLength(4)); + final ids = rendered.blocks.map((b) => b.attributes['listId']).toList(); + expect(ids, everyElement(isNotNull)); + expect(ids[0], ids[1]); + expect(ids[2], ids[3]); + expect(ids[0], isNot(ids[2])); + expect(rendered.blocks.map((b) => b.attributes['listOrdinal']), [ + '1', + '2', + '1', + '2', + ]); + for (var i = 0; i < rendered.blocks.length; i++) { + final span = rendered.blocks[i].sourceSpan!; + final original = originalItems[i % 2].span; + expect(span.filePath, original.filePath); + expect(span.startOffset, original.startOffset); + expect(span.endOffset, original.endOffset); + } + expect( + renderer + .toBusyDocument(resolved.document) + .blocks + .map((b) => b.attributes['listId']), + ids, + ); + }, + ); + test('workspace exposes resolver errors with exact source spans', () async { final fixture = await _ResolvedFixture.create(); addTearDown(fixture.dispose); diff --git a/test/src/writerside_pdf_export_test.dart b/test/src/writerside_pdf_export_test.dart index 5aa98af4..73b32de6 100644 --- a/test/src/writerside_pdf_export_test.dart +++ b/test/src/writerside_pdf_export_test.dart @@ -21,6 +21,56 @@ import 'package:path/path.dart' as p; void main() { final compiler = _bundledTypstCompiler(); + test('repeated list includes restart numbering in the final PDF', () async { + final output = await Directory.systemTemp.createTemp( + 'writerside-list-includes-', + ); + addTearDown(() => output.delete(recursive: true)); + final fixture = Directory( + 'test/fixtures/writerside/repeated_list_include', + ).absolute; + final destination = p.join(output.path, 'lists.pdf'); + final result = + await WritersidePdfExportService( + markdownExporter: MarkdownPdfExportService( + compilerLocator: TypstCompilerLocator( + environment: {'BUSYMARK_TYPST_PATH': compiler!}, + ), + templateLoader: () => + File('assets/export/markdown.typ').readAsString(), + ), + ).export( + WritersidePdfExportRequest( + moduleRoot: fixture.path, + instanceId: 'guide', + destinationPath: destination, + overwrite: false, + ), + ); + final extracted = await Process.run('/usr/bin/pdftotext', [ + '-layout', + destination, + '-', + ]); + expect(extracted.exitCode, 0, reason: '${extracted.stderr}'); + final text = extracted.stdout as String; + final items = RegExp( + r'\b(\d+)\.\s+Included (first|second)\b', + ).allMatches(text); + expect(items.map((match) => '${match[1]} ${match[2]}'), [ + '1 first', + '2 second', + '1 first', + '2 second', + ]); + expect( + result.warnings, + isEmpty, + reason: result.warnings + .map((w) => '${w.code}: ${w.destination}') + .join('\n'), + ); + }, skip: compiler == null || !File('/usr/bin/pdftotext').existsSync()); test( 'final PDF keeps alphabetic starts, separate decimal lists and procedure numbering', () async { From 94db0f89305dac93a619526164476e6f21b7ca9b Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 12 Sep 2026 20:36:40 -0700 Subject: [PATCH 06/19] Refactor PDF export service to improve diagnostic reporting with detailed error messages. --- assets/export/markdown.typ | 20 +++++++++++--- .../export/writerside_pdf_export_service.dart | 27 ++++++++++++++++--- test/src/pdf_export_options_render_test.dart | 19 +++++++++++++ test/src/writerside_pdf_export_test.dart | 7 ++++- 4 files changed, 65 insertions(+), 8 deletions(-) diff --git a/assets/export/markdown.typ b/assets/export/markdown.typ index 9af97b2d..83957881 100644 --- a/assets/export/markdown.typ +++ b/assets/export/markdown.typ @@ -17,6 +17,14 @@ #let content-height = (geometry.heightPt - margins.top - margins.bottom) * 1pt #let running-visible() = options.showHeaderFooterOnFirstPage or counter(page).get().first() > 1 #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 +37,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) + }) } } }, ) diff --git a/lib/src/export/writerside_pdf_export_service.dart b/lib/src/export/writerside_pdf_export_service.dart index 5e7f4248..1e97332a 100644 --- a/lib/src/export/writerside_pdf_export_service.dart +++ b/lib/src/export/writerside_pdf_export_service.dart @@ -97,8 +97,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 +114,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; @@ -407,6 +407,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, diff --git a/test/src/pdf_export_options_render_test.dart b/test/src/pdf_export_options_render_test.dart index ca3b1880..7674d530 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 = diff --git a/test/src/writerside_pdf_export_test.dart b/test/src/writerside_pdf_export_test.dart index 73b32de6..5cafe022 100644 --- a/test/src/writerside_pdf_export_test.dart +++ b/test/src/writerside_pdf_export_test.dart @@ -806,6 +806,7 @@ void main() { + '''); final exporter = _RecordingMarkdownExporter(); @@ -830,7 +831,11 @@ void main() { .having( (error) => error.detail, 'detail', - contains('writerside.tree.missing-topic'), + allOf( + contains('guide.tree:3: Missing topic "missing.topic"'), + contains('guide.tree:4: Missing topic "another-missing.topic"'), + contains('writerside.tree.missing-topic'), + ), ), ), ); From bb3eb1815824a4b7f136dd991d2a087f49bf74f5 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 12 Sep 2026 20:47:14 -0700 Subject: [PATCH 07/19] Improve anchor resolution for Writerside documents and Markdown parsing, ensuring accurate alias linking and verbatim block handling. --- lib/src/markdown/markdown_parser.dart | 20 ++- lib/src/writerside/writerside_document.dart | 34 +++- .../writerside_document_parser.dart | 8 +- .../writerside_document_resolver.dart | 8 +- .../writerside/writerside_module_service.dart | 3 +- .../markdown_export_compatibility/guide.tree | 4 + .../images/.gitkeep | 1 + .../topics/main.md | 32 ++++ .../topics/workflow.md | 9 + .../writerside.cfg | 1 + test/src/writerside_document_test.dart | 111 ++++++++++++ ...de_markdown_export_compatibility_test.dart | 166 ++++++++++++++++++ 12 files changed, 386 insertions(+), 11 deletions(-) create mode 100644 test/fixtures/writerside/markdown_export_compatibility/guide.tree create mode 100644 test/fixtures/writerside/markdown_export_compatibility/images/.gitkeep create mode 100644 test/fixtures/writerside/markdown_export_compatibility/topics/main.md create mode 100644 test/fixtures/writerside/markdown_export_compatibility/topics/workflow.md create mode 100644 test/fixtures/writerside/markdown_export_compatibility/writerside.cfg create mode 100644 test/src/writerside_markdown_export_compatibility_test.dart diff --git a/lib/src/markdown/markdown_parser.dart b/lib/src/markdown/markdown_parser.dart index 6f890c62..f751e1cc 100644 --- a/lib/src/markdown/markdown_parser.dart +++ b/lib/src/markdown/markdown_parser.dart @@ -467,9 +467,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 +496,7 @@ class MarkdownParser { block.kind == BusyBlockKind.heading && headingSourceIndex < headingSourceChunks.length ? headingSourceChunks[headingSourceIndex++] - : null, + : nextVerbatimChunk(block), ), ]; if (sourceChunks.any((chunk) => chunk.protectEdits)) { diff --git a/lib/src/writerside/writerside_document.dart b/lib/src/writerside/writerside_document.dart index 838c0ac4..5acb72fe 100644 --- a/lib/src/writerside/writerside_document.dart +++ b/lib/src/writerside/writerside_document.dart @@ -80,18 +80,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 +112,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 +134,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_resolver.dart b/lib/src/writerside/writerside_document_resolver.dart index 1fec08a8..97711fb0 100644 --- a/lib/src/writerside/writerside_document_resolver.dart +++ b/lib/src/writerside/writerside_document_resolver.dart @@ -450,6 +450,10 @@ class _ResolveState { final target = anchor.isEmpty ? null : targetTopic?.document.contentById(anchor)?.first; + // Alias lookup must link to the ID that the renderer actually emits. + final resolvedAnchor = target is WritersideMarkdownBlockNode + ? target.block.attributes['id'] ?? anchor + : anchor; final instance = context.instance; var available = targetModule != null && @@ -615,8 +619,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, diff --git a/lib/src/writerside/writerside_module_service.dart b/lib/src/writerside/writerside_module_service.dart index 69f771a2..8d3d60ea 100644 --- a/lib/src/writerside/writerside_module_service.dart +++ b/lib/src/writerside/writerside_module_service.dart @@ -1095,7 +1095,8 @@ class WritersideModuleService { } else if (anchor != null && anchor.isNotEmpty) { final decodedAnchor = _decodeMarkdownAnchor(anchor); final anchors = target.elementIds.map((item) => item.id).toSet(); - if (!anchors.contains(decodedAnchor)) { + if (!anchors.contains(decodedAnchor) && + target.document.contentById(decodedAnchor) == null) { diagnostics.add( Diagnostic( code: 'markdown.link.unresolved-anchor', 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/src/writerside_document_test.dart b/test/src/writerside_document_test.dart index 0842391d..57d62821 100644 --- a/test/src/writerside_document_test.dart +++ b/test/src/writerside_document_test.dart @@ -18,6 +18,117 @@ import 'package:path/path.dart' as p; import 'package:xml/xml.dart'; void main() { + for (final protectedSource in [false, true]) { + test( + 'complex Markdown retains semantic XML spans (protected=$protectedSource)', + () async { + final file = File( + 'test/fixtures/writerside/markdown_export_compatibility/topics/main.md', + ); + final source = + '${await file.readAsString()}${protectedSource ? '\n> [nested]: https://example.com\n> Nested definition.\n' : ''}'; + final markdown = const MarkdownParser().parse( + filePath: file.absolute.path, + source: source, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ); + expect( + markdown.busyDocument.blocks.any((b) => b.isSourceOnly), + protectedSource, + ); + final document = const WritersideDocumentParser().parseMarkdown( + filePath: markdown.filePath, + source: source, + markdown: markdown.busyDocument, + ); + expect(document.nodes.whereType(), isEmpty); + final structure = document.elements.singleWhere( + (e) => e.name == 'show-structure', + ); + expect(structure.span.startLine, 1); + expect(structure.rawSource, ''); + final notes = document.elements.where((e) => e.name == 'note').toList(); + expect(notes, hasLength(2)); + const note = '

    Repeated semantic note.

    '; + final positions = RegExp( + RegExp.escape(note), + ).allMatches(source).map((m) => m.start).toList(); + // The first spelling is fenced code, not a semantic note. + expect(notes.map((e) => e.span.startOffset), positions.skip(1)); + for (final node in notes) { + expect( + source.substring(node.span.startOffset, node.span.endOffset), + note, + ); + } + expect( + const WritersideDocumentSerializer().serialize(document), + source, + ); + }, + ); + } + + test('Writerside heading aliases prefer exact IDs and reject ambiguity', () { + const source = '''# Links + +## Read.file + +## Explicit {id="read-file"} + +## Same.file + +## Same/file + +## Only.file {id="custom"} +'''; + final markdown = const MarkdownParser().parse( + filePath: '/tmp/aliases.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ); + final document = const WritersideDocumentParser().parseMarkdown( + filePath: markdown.filePath, + source: source, + markdown: markdown.busyDocument, + ); + expect(document.contentById('read-file')!.first.plainText, 'Explicit'); + expect(document.contentById('same-file'), isNull); + expect(document.contentById('only-file'), isNull); + expect(document.contentById('custom')!.first.plainText, 'Only.file'); + }); + + test('malformed semantic XML is still reported after a scanner mismatch', () { + const source = '''# Invalid + +1. First + + Continuation. +2. Second + +

    Unclosed paragraph +'''; + final markdown = const MarkdownParser().parse( + filePath: '/tmp/invalid.md', + source: source, + mode: MarkdownMode.writersideMarkdown, + validateLocalReferences: false, + ); + final document = const WritersideDocumentParser().parseMarkdown( + filePath: markdown.filePath, + source: source, + markdown: markdown.busyDocument, + ); + expect( + document.nodes.whereType().map( + (node) => node.rawSource, + ), + contains(contains('

    Unclosed paragraph')), + ); + }); + test( 'lossless XML nodes preserve exact duplicate ranges and generic markup', () { diff --git a/test/src/writerside_markdown_export_compatibility_test.dart b/test/src/writerside_markdown_export_compatibility_test.dart new file mode 100644 index 00000000..eaa19ff1 --- /dev/null +++ b/test/src/writerside_markdown_export_compatibility_test.dart @@ -0,0 +1,166 @@ +import 'dart:io'; + +import 'package:busymark/src/export/html_export_service.dart'; +import 'package:busymark/src/export/markdown_pdf_export_service.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:html/parser.dart' as html; +import 'package:path/path.dart' as p; +import 'package:xml/xml.dart'; + +void main() { + final fixture = Directory( + 'test/fixtures/writerside/markdown_export_compatibility', + ).absolute; + late Directory output; + setUp(() async { + output = await Directory.systemTemp.createTemp('writerside-md-export-'); + }); + tearDown(() => output.delete(recursive: true)); + + test( + 'HTML retains complex Markdown and resolves both heading ID spellings', + () async { + final result = await const HtmlExportService().exportWriterside( + projectRoot: fixture.path, + moduleRoot: fixture.path, + instanceId: 'guide', + destinationPath: p.join(output.path, 'site'), + ); + expect(result.warnings, isEmpty); + final document = html.parse( + await File(result.entryPointPath).readAsString(), + ); + final article = document.querySelector('article')!; + final links = article + .querySelectorAll('a') + .where( + (link) => [ + 'Writerside heading', + 'Existing Markdown heading', + ].contains(link.text), + ) + .toList(); + expect(links, hasLength(2)); + expect( + links.map((link) => link.attributes['href']).toSet(), + hasLength(1), + ); + final target = Uri.parse(links.first.attributes['href']!); + final targetDocument = html.parse( + await File( + p.join(p.dirname(result.entryPointPath), target.path), + ).readAsString(), + ); + expect( + targetDocument.getElementById(target.fragment)!.text, + 'Step 6: Update CMakeLists.txt', + ); + expect( + article.querySelectorAll('pre code').map((code) => code.text), + containsAll([ + contains("echo ''"), + contains('

    Repeated semantic note.

    '), + ]), + ); + expect(article.querySelectorAll('.admonition'), hasLength(2)); + for (final phrase in [ + 'Before the list & after the metadata.', + 'A continuation paragraph.', + 'Nested action', + 'Second action', + 'Between the notes.', + 'Final compatibility paragraph.', + ]) { + expect(phrase.allMatches(article.text), hasLength(1), reason: phrase); + } + }, + ); + + final typst = Platform.environment['BUSYMARK_TYPST_PATH']; + test( + 'PDF retains complex Markdown and resolved heading links', + () async { + final result = + await WritersidePdfExportService( + markdownExporter: MarkdownPdfExportService( + compilerLocator: TypstCompilerLocator( + environment: {'BUSYMARK_TYPST_PATH': typst!}, + ), + templateLoader: () => + File('assets/export/markdown.typ').readAsString(), + ), + ).export( + WritersidePdfExportRequest( + moduleRoot: fixture.path, + instanceId: 'guide', + destinationPath: p.join(output.path, 'result.pdf'), + overwrite: false, + ), + ); + expect( + result.warnings, + isEmpty, + reason: result.warnings + .map((w) => '${w.code}: ${w.destination}') + .join('\n'), + ); + final extracted = await Process.run('/usr/bin/pdftotext', [ + '-layout', + result.destinationPath, + '-', + ]); + expect(extracted.exitCode, 0, reason: '${extracted.stderr}'); + final text = extracted.stdout as String; + for (final phrase in [ + 'Before the list & after the metadata.', + 'A continuation paragraph.', + 'Nested action', + 'Second action', + 'Between the notes.', + 'Final compatibility paragraph.', + 'Step 6: Update CMakeLists.txt', + 'Retained CMake explanation.', + 'Following chapter explanation.', + ]) { + expect(phrase.allMatches(text), hasLength(1), reason: phrase); + } + expect('Repeated semantic note.'.allMatches(text), hasLength(3)); + final linked = await Process.run('/usr/bin/pdftohtml', [ + '-xml', + '-i', + '-stdout', + result.destinationPath, + ]); + expect(linked.exitCode, 0, reason: '${linked.stderr}'); + final xml = XmlDocument.parse(linked.stdout as String); + final links = xml + .findAllElements('a') + .where( + (a) => [ + 'Writerside heading', + 'Existing Markdown heading', + ].contains(a.innerText), + ) + .toList(); + expect(links, hasLength(2)); + expect(links.map((a) => a.getAttribute('href')).toSet(), hasLength(1)); + final targetPage = xml + .findAllElements('page') + .singleWhere( + (page) => page.innerText.contains('Step 6: Update CMakeLists.txt'), + ); + expect( + links.first.getAttribute('href'), + endsWith('#${targetPage.getAttribute('number')}'), + ); + }, + skip: + typst == null || + !File(typst).existsSync() || + !File('/usr/bin/pdftotext').existsSync() || + !File('/usr/bin/pdftohtml').existsSync(), + ); +} From 81546378d90030f5520e01004d953d07c9fec7a3 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 12 Sep 2026 22:17:45 -0700 Subject: [PATCH 08/19] Add support for PDF title page customization, including title, subtitle, author, and more. Update export workflows to handle title page options seamlessly. --- assets/export/markdown.typ | 44 +- docs/development/export-verification.md | 51 ++ docs/pdf-export.md | 57 +- lib/l10n/app_ar.arb | 11 + lib/l10n/app_de.arb | 11 + lib/l10n/app_en.arb | 22 + lib/l10n/app_es.arb | 11 + lib/l10n/app_et.arb | 11 + lib/l10n/app_fa.arb | 11 + lib/l10n/app_fr.arb | 11 + lib/l10n/app_hi.arb | 11 + lib/l10n/app_id.arb | 11 + lib/l10n/app_it.arb | 11 + lib/l10n/app_ja.arb | 11 + lib/l10n/app_ko.arb | 11 + lib/l10n/app_nb.arb | 11 + lib/l10n/app_nl.arb | 11 + lib/l10n/app_pl.arb | 11 + lib/l10n/app_pt.arb | 11 + lib/l10n/app_pt_BR.arb | 11 + lib/l10n/app_ru.arb | 11 + lib/l10n/app_tr.arb | 11 + lib/l10n/app_uk.arb | 11 + lib/l10n/app_vi.arb | 11 + lib/l10n/app_zh.arb | 11 + lib/l10n/app_zh_CN.arb | 11 + lib/l10n/generated/app_localizations.dart | 66 +++ lib/l10n/generated/app_localizations_ar.dart | 34 ++ lib/l10n/generated/app_localizations_de.dart | 36 ++ lib/l10n/generated/app_localizations_en.dart | 35 ++ lib/l10n/generated/app_localizations_es.dart | 36 ++ lib/l10n/generated/app_localizations_et.dart | 35 ++ lib/l10n/generated/app_localizations_fa.dart | 35 ++ lib/l10n/generated/app_localizations_fr.dart | 36 ++ lib/l10n/generated/app_localizations_hi.dart | 36 ++ lib/l10n/generated/app_localizations_id.dart | 35 ++ lib/l10n/generated/app_localizations_it.dart | 36 ++ lib/l10n/generated/app_localizations_ja.dart | 33 ++ lib/l10n/generated/app_localizations_ko.dart | 33 ++ lib/l10n/generated/app_localizations_nb.dart | 36 ++ lib/l10n/generated/app_localizations_nl.dart | 36 ++ lib/l10n/generated/app_localizations_pl.dart | 35 ++ lib/l10n/generated/app_localizations_pt.dart | 72 +++ lib/l10n/generated/app_localizations_ru.dart | 36 ++ lib/l10n/generated/app_localizations_tr.dart | 36 ++ lib/l10n/generated/app_localizations_uk.dart | 36 ++ lib/l10n/generated/app_localizations_vi.dart | 35 ++ lib/l10n/generated/app_localizations_zh.dart | 66 +++ lib/src/export/export_options.dart | 6 + lib/src/export/export_options_editor.dart | 82 ++- .../export/markdown_pdf_export_service.dart | 10 + lib/src/export/markdown_pdf_export_ui.dart | 39 +- lib/src/export/markdown_pdf_models.dart | 6 + lib/src/export/pdf_title_page.dart | 85 +++ lib/src/export/typst_payload_builder.dart | 9 + lib/src/export/workspace_export_ui.dart | 16 +- .../export/writerside_pdf_export_service.dart | 3 + lib/src/export/writerside_pdf_export_ui.dart | 1 + lib/src/export/writerside_pdf_models.dart | 2 + .../writerside/pdf_front_matter/guide.tree | 4 + .../pdf_front_matter/topics/body.topic | 9 + .../pdf_front_matter/topics/hidden.topic | 3 + .../pdf_front_matter/writerside.cfg | 4 + test/src/localization_audit_test.dart | 3 + test/src/pdf_export_options_render_test.dart | 10 +- test/src/pdf_front_matter_render_test.dart | 528 ++++++++++++++++++ test/src/pdf_title_page_test.dart | 205 +++++++ test/src/pdf_title_page_ui_test.dart | 433 ++++++++++++++ 68 files changed, 2733 insertions(+), 15 deletions(-) create mode 100644 lib/src/export/pdf_title_page.dart create mode 100644 test/fixtures/writerside/pdf_front_matter/guide.tree create mode 100644 test/fixtures/writerside/pdf_front_matter/topics/body.topic create mode 100644 test/fixtures/writerside/pdf_front_matter/topics/hidden.topic create mode 100644 test/fixtures/writerside/pdf_front_matter/writerside.cfg create mode 100644 test/src/pdf_front_matter_render_test.dart create mode 100644 test/src/pdf_title_page_test.dart create mode 100644 test/src/pdf_title_page_ui_test.dart diff --git a/assets/export/markdown.typ b/assets/export/markdown.typ index 83957881..a79e5103 100644 --- a/assets/export/markdown.typ +++ b/assets/export/markdown.typ @@ -15,7 +15,11 @@ #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") @@ -447,9 +451,45 @@ } } +#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 cover-content = { + set text(font: typography.bodyFont, size: body-size, + top-edge: "ascender", bottom-edge: "descender") + set par(leading: 0.4em, spacing: 0pt) + let rows = (text(size: 2.4 * body-size, weight: "bold", + fill: rgb(options.accentColor), cover.title),) + let subtitle = cover.at("subtitle", default: "") + if subtitle != "" { rows.push(text(size: 1.3 * body-size, subtitle)) } + let details = () + for key in ("author", "organization", "version", "date") { + let value = cover.at(key, default: "") + if value != "" { details.push(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 and natural.width <= width, + message: "The PDF title page does not fit the selected page size and margins. Shorten the title-page text or choose a larger content area.") + 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..b9ad86da 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,53 @@ 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. + +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/pdf-export.md b/docs/pdf-export.md index 089877d7..e53f987e 100644 --- a/docs/pdf-export.md +++ b/docs/pdf-export.md @@ -13,7 +13,13 @@ 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. - **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 @@ -28,20 +34,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/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/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..73976f4c 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 @@ -217,6 +251,35 @@ class _ExportOptionsDialogState extends State { ], ); + 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 +294,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 +319,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 +333,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 +360,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 +378,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/markdown_pdf_export_service.dart b/lib/src/export/markdown_pdf_export_service.dart index 75a48431..468512ae 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( 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/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 1e97332a..d397e3a8 100644 --- a/lib/src/export/writerside_pdf_export_service.dart +++ b/lib/src/export/writerside_pdf_export_service.dart @@ -135,6 +135,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, 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/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/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/pdf_export_options_render_test.dart b/test/src/pdf_export_options_render_test.dart index 7674d530..d7c84c3a 100644 --- a/test/src/pdf_export_options_render_test.dart +++ b/test/src/pdf_export_options_render_test.dart @@ -250,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..9a0481be --- /dev/null +++ b/test/src/pdf_front_matter_render_test.dart @@ -0,0 +1,528 @@ +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'); + +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; + } + + 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, + ); +} + +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 [], + ); + } +} From 1ba18a8e44eaf62bfee7d2fe8eb8e2e180cc171c Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 12 Sep 2026 22:35:36 -0700 Subject: [PATCH 09/19] Improve title page rendering in PDF export by adding line overflow handling and explicit error messages. --- assets/export/markdown.typ | 39 ++++++-- docs/development/export-verification.md | 6 ++ docs/pdf-export.md | 4 +- test/src/pdf_front_matter_render_test.dart | 108 +++++++++++++++++++++ 4 files changed, 150 insertions(+), 7 deletions(-) diff --git a/assets/export/markdown.typ b/assets/export/markdown.typ index a79e5103..7230df44 100644 --- a/assets/export/markdown.typ +++ b/assets/export/markdown.typ @@ -455,18 +455,46 @@ 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 { + // A constrained paragraph frame hides overflowing glyphs. Build lines at + // spaces and measure each candidate with infinite width in its actual text + // style. Explicit breaks also prevent Unicode no-break rules from joining + // individually fitting words into an overflowing line. Non-breaking spaces + // stay inside words, which must fit as a whole. + let lines = () + for source-line in value.split("\n") { + let line = "" + for word in source-line.split(" ") { + let candidate = if line == "" { word } else { line + " " + word } + if measure(text(candidate), width: auto).width <= width { + line = candidate + } else { + assert(measure(text(word), width: auto).width <= width, + message: fit-error) + lines.push(line) + line = word + } + } + lines.push(line) + } + for (index, line) in lines.enumerate() { + if index > 0 { linebreak() } + text(line) + } + } let cover-content = { set text(font: typography.bodyFont, size: body-size, - top-edge: "ascender", bottom-edge: "descender") + 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.title),) + fill: rgb(options.accentColor), cover-text(cover.title)),) let subtitle = cover.at("subtitle", default: "") - if subtitle != "" { rows.push(text(size: 1.3 * body-size, subtitle)) } + 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(text(value)) } + if value != "" { details.push(cover-text(value)) } } if details.len() > 0 { rows.push(stack(spacing: 0.5em, ..details)) @@ -474,8 +502,7 @@ align(center, stack(spacing: 1.5em, ..rows)) } let natural = measure(cover-content, width: width) - assert(natural.height <= content-height and natural.width <= width, - message: "The PDF title page does not fit the selected page size and margins. Shorten the title-page text or choose a larger content area.") + 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)) } diff --git a/docs/development/export-verification.md b/docs/development/export-verification.md index b9ad86da..796fc254 100644 --- a/docs/development/export-verification.md +++ b/docs/development/export-verification.md @@ -72,6 +72,12 @@ displayed page reference against its body paragraph’s physical page. Additiona 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 title page counts as physical page 1 but never emits running text or its visible number. Subsequent pages obey configured header/footer placement and diff --git a/docs/pdf-export.md b/docs/pdf-export.md index e53f987e..cbea68d3 100644 --- a/docs/pdf-export.md +++ b/docs/pdf-export.md @@ -19,7 +19,9 @@ Markdown and Writerside offer the same PDF controls: - **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. + a running header, footer, or visible page number. Cover text wraps at spaces. + If a word 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 diff --git a/test/src/pdf_front_matter_render_test.dart b/test/src/pdf_front_matter_render_test.dart index 9a0481be..35cf5d8f 100644 --- a/test/src/pdf_front_matter_render_test.dart +++ b/test/src/pdf_front_matter_render_test.dart @@ -487,6 +487,114 @@ void main() { }, skip: skip, ); + + 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 { From 8230ac3c9c26200a30ed3e712d1cd636fe13957f Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 13 Sep 2026 00:32:18 -0700 Subject: [PATCH 10/19] =?UTF-8?q?Fixed=20cover=20layout=20(assets/export/m?= =?UTF-8?q?arkdown.typ:459)=20to=20preserve=20Typst=E2=80=99s=20Unicode=20?= =?UTF-8?q?wrapping=20while=20checking=20unbreakable=20spans=20in=20their?= =?UTF-8?q?=20actual=20font=20and=20size?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/flutter-linux.yml | 1 + assets/export/markdown.typ | 60 ++++++++----- docs/development/export-verification.md | 6 ++ docs/pdf-export.md | 5 +- test/src/pdf_front_matter_render_test.dart | 100 +++++++++++++++++++++ 5 files changed, 148 insertions(+), 24 deletions(-) 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 7230df44..4037a4aa 100644 --- a/assets/export/markdown.typ +++ b/assets/export/markdown.typ @@ -457,31 +457,47 @@ 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 { - // A constrained paragraph frame hides overflowing glyphs. Build lines at - // spaces and measure each candidate with infinite width in its actual text - // style. Explicit breaks also prevent Unicode no-break rules from joining - // individually fitting words into an overflowing line. Non-breaking spaces - // stay inside words, which must fit as a whole. - let lines = () - for source-line in value.split("\n") { - let line = "" - for word in source-line.split(" ") { - let candidate = if line == "" { word } else { line + " " + word } - if measure(text(candidate), width: auto).width <= width { - line = candidate - } else { - assert(measure(text(word), width: auto).width <= width, - message: fit-error) - lines.push(line) - line = word + 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 } - lines.push(line) - } - for (index, line) in lines.enumerate() { - if index > 0 { linebreak() } - text(line) + 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, diff --git a/docs/development/export-verification.md b/docs/development/export-verification.md index 796fc254..57bac76e 100644 --- a/docs/development/export-verification.md +++ b/docs/development/export-verification.md @@ -78,6 +78,12 @@ 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 diff --git a/docs/pdf-export.md b/docs/pdf-export.md index cbea68d3..ed6bb69f 100644 --- a/docs/pdf-export.md +++ b/docs/pdf-export.md @@ -19,8 +19,9 @@ Markdown and Writerside offer the same PDF controls: - **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 wraps at spaces. - If a word is wider than the content area or the cover is too tall, export + 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 diff --git a/test/src/pdf_front_matter_render_test.dart b/test/src/pdf_front_matter_render_test.dart index 35cf5d8f..7ec46c8a 100644 --- a/test/src/pdf_front_matter_render_test.dart +++ b/test/src/pdf_front_matter_render_test.dart @@ -27,6 +27,8 @@ NESTED_BODY_SENTINEL stays linked. 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']; @@ -85,6 +87,32 @@ void main() { 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]) { @@ -488,6 +516,78 @@ void main() { 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', From 79f671ef8550b06df0502e798ed97c7124163ce8 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 13 Sep 2026 11:18:43 -0700 Subject: [PATCH 11/19] Fixed issues: - Unique block IDs across composed Writerside exports. - Canonical image containment, preserving shared-module assets. - Native PDF footnotes with repeated references. - Correct cross-instance topic selection. - Correct HTML anchor targets, outline order, and numbering. - Source-located warnings for malformed links. - PDF page count returns null instead of an unreliable estimate. --- assets/export/markdown.typ | 45 +- lib/src/export/html_document_writer.dart | 9 +- lib/src/export/html_export_links.dart | 21 + lib/src/export/html_publication_plan.dart | 35 ++ lib/src/export/markdown_export_mapper.dart | 62 +- .../export/markdown_pdf_export_service.dart | 35 +- .../export/markdown_visualization_export.dart | 2 +- lib/src/export/pdf_footnotes.dart | 73 +++ .../export/writerside_pdf_export_service.dart | 104 ++-- .../writerside_document_resolver.dart | 53 ++ test/src/export_review_regression_test.dart | 533 ++++++++++++++++++ .../src/markdown_pdf_export_service_test.dart | 2 +- 12 files changed, 891 insertions(+), 83 deletions(-) create mode 100644 lib/src/export/pdf_footnotes.dart create mode 100644 test/src/export_review_regression_test.dart diff --git a/assets/export/markdown.typ b/assets/export/markdown.typ index 4037a4aa..7624db60 100644 --- a/assets/export/markdown.typ +++ b/assets/export/markdown.typ @@ -118,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) @@ -202,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 @@ -247,7 +267,7 @@ 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) } @@ -291,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", ()) @@ -300,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( @@ -342,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" @@ -361,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, @@ -376,7 +397,7 @@ if item.kind == "image" { render-display-image(item) } else { - render-inlines((item,)) + render-inlines((item,), render-block) } } } @@ -444,10 +465,10 @@ raw(value-or(block-data, "text", ""), block: true), ) } else if kind == "group" { - if inlines.len() > 0 { par(render-inlines(inlines)) } + 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) } } diff --git a/lib/src/export/html_document_writer.dart b/lib/src/export/html_document_writer.dart index f4def6d3..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( @@ -208,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. @@ -463,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?) { diff --git a/lib/src/export/html_export_links.dart b/lib/src/export/html_export_links.dart index cf586386..f8798deb 100644 --- a/lib/src/export/html_export_links.dart +++ b/lib/src/export/html_export_links.dart @@ -67,6 +67,27 @@ class HtmlExportLinks { 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, { + int line = 1, }) async { if (value == null || value.trim().isEmpty) return null; final destination = value.trim(); 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 0896bd41..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,8 +16,62 @@ 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( @@ -47,7 +102,9 @@ class MarkdownExportMapper { ); } currentVariant = variant; - final override = blockOverrides[block.id]; + final override = block.kind == BusyBlockKind.codeBlock + ? blockOverrides[block.id] + : null; if (override != null) { result.add(_withAnchor(override, block)); index++; @@ -110,6 +167,7 @@ class MarkdownExportMapper { attributes: { ...mapped.attributes, if (source.attributes['pdf-anchor'] case final anchor?) 'anchor': anchor, + if (source.attributes['pdf-footnote-id'] case final id?) 'footnoteId': id, }, ); diff --git a/lib/src/export/markdown_pdf_export_service.dart b/lib/src/export/markdown_pdf_export_service.dart index 468512ae..5a96ae23 100644 --- a/lib/src/export/markdown_pdf_export_service.dart +++ b/lib/src/export/markdown_pdf_export_service.dart @@ -202,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, @@ -249,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_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..12f6588c --- /dev/null +++ b/lib/src/export/pdf_footnotes.dart @@ -0,0 +1,73 @@ +import 'package:html/parser.dart' as html; + +import '../markdown/busymark_document.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, + ]) + 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 = const RawHtmlAdapter().parseRawHtmlBlock( + '
    ${definition.innerHtml}
    ', + nextId, + ); + definitions.add( + BusyBlock( + id: nextId(), + kind: BusyBlockKind.unknown, + attributes: {...origin, 'pdf-footnote-id': definition.id}, + children: parsed?.safe == true + ? parsed!.blocks.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()); +} diff --git a/lib/src/export/writerside_pdf_export_service.dart b/lib/src/export/writerside_pdf_export_service.dart index d397e3a8..c3abf915 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'; @@ -256,13 +257,17 @@ class WritersidePdfExportService { detail: 'Unknown TOC module origin: ${node.origin}.', ); } - if (startTopic != null && + 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 (!node.workInProgress) { + if (publishesTopic && !node.workInProgress) { addReference(node.topicReference, owner, hidden: node.hidden); } for (final child in node.children) { @@ -287,11 +292,6 @@ class WritersidePdfExportService { ), ); } - if (selected.isEmpty) { - for (final reference in instance.topicFileSet) { - addReference(reference, module, hidden: false); - } - } if (selected.length > maximumTopics) { throw const WritersidePdfExportException( WritersidePdfFailureCode.invalidRequest, @@ -367,17 +367,25 @@ class WritersidePdfExportService { final assets = await _resolveBusyAssets( 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), ); @@ -495,9 +503,11 @@ class WritersidePdfExportService { 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 && @@ -518,9 +528,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)); @@ -548,6 +560,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'] = anchor(topic.filePath, footnoteId); + } final id = attributes['id']; final candidate = block.id == 'writerside-document-title' ? anchor(topic.filePath, '') @@ -564,9 +579,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)), @@ -580,33 +597,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(); @@ -615,7 +649,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/writerside/writerside_document_resolver.dart b/lib/src/writerside/writerside_document_resolver.dart index 97711fb0..eb2504bb 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'; @@ -84,6 +85,44 @@ class _ResolveState { final WritersideResolveContext context; final List diagnostics = []; final Set _unresolvedVariables = {}; + final Map> _footnoteTargets = {}; + + 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, { @@ -865,6 +904,20 @@ class _ResolveState { children: inline.children.map(resolveInline).toList(growable: false), ); 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('#')) { + try { + if (_markdownFootnoteTargets( + topic, + ).contains(Uri.decodeComponent(destination.substring(1)))) { + return resolved; + } + } on FormatException { + // Let normal link validation report an unusable authored destination. + } + } final attributes = _resolveLink( {...resolved.attributes, 'href': resolved.destination ?? ''}, module: module, diff --git a/test/src/export_review_regression_test.dart b/test/src/export_review_regression_test.dart new file mode 100644 index 00000000..ba76c2e4 --- /dev/null +++ b/test/src/export_review_regression_test.dart @@ -0,0 +1,533 @@ +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!; + } + + 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, + ); + } +} diff --git a/test/src/markdown_pdf_export_service_test.dart b/test/src/markdown_pdf_export_service_test.dart index d91e237e..17833536 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), From 2611f599a87f014626345080b7e502463598e18e Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 13 Sep 2026 12:14:57 -0700 Subject: [PATCH 12/19] Add support for footnote targets and improved occurrence tracking in Writerside export processes. --- lib/src/export/pdf_footnotes.dart | 91 +++++++++++- .../export/writerside_pdf_export_service.dart | 18 ++- lib/src/writerside/writerside_document.dart | 7 + .../writerside_document_renderer.dart | 3 + .../writerside_document_resolver.dart | 23 ++- test/src/export_review_regression_test.dart | 89 +++++++++++ test/src/markdown_math_export_test.dart | 140 ++++++++++++++++++ 7 files changed, 359 insertions(+), 12 deletions(-) diff --git a/lib/src/export/pdf_footnotes.dart b/lib/src/export/pdf_footnotes.dart index 12f6588c..98da0c5c 100644 --- a/lib/src/export/pdf_footnotes.dart +++ b/lib/src/export/pdf_footnotes.dart @@ -1,6 +1,8 @@ +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'; @@ -18,6 +20,7 @@ BusyDocument preparePdfFootnotes(BusyDocument document) { for (final name in [ writersideSourceModuleRootAttribute, writersideSourceTopicPathAttribute, + writersideSourceOccurrenceAttribute, ]) if (block.attributes[name] case final value?) name: value, }; @@ -36,17 +39,14 @@ BusyDocument preparePdfFootnotes(BusyDocument document) { for (final backlink in definition.querySelectorAll('.footnote-backref')) { backlink.remove(); } - final parsed = const RawHtmlAdapter().parseRawHtmlBlock( - '
    ${definition.innerHtml}
    ', - nextId, - ); + final parsed = _definitionBlocks(definition, nextId); definitions.add( BusyBlock( id: nextId(), kind: BusyBlockKind.unknown, attributes: {...origin, 'pdf-footnote-id': definition.id}, - children: parsed?.safe == true - ? parsed!.blocks.map(withOrigin).toList() + children: parsed != null + ? parsed.map(withOrigin).toList() : [ BusyBlock( id: nextId(), @@ -71,3 +71,82 @@ BusyDocument preparePdfFootnotes(BusyDocument document) { 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/writerside_pdf_export_service.dart b/lib/src/export/writerside_pdf_export_service.dart index c3abf915..932140f1 100644 --- a/lib/src/export/writerside_pdf_export_service.dart +++ b/lib/src/export/writerside_pdf_export_service.dart @@ -499,6 +499,17 @@ 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; @@ -510,7 +521,10 @@ class WritersidePdfExportService { ); } 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('#'); @@ -561,7 +575,7 @@ class WritersidePdfExportService { final attributes = {...block.attributes}; final source = sourceContext(attributes); if (attributes['pdf-footnote-id'] case final footnoteId?) { - attributes['pdf-footnote-id'] = anchor(topic.filePath, footnoteId); + attributes['pdf-footnote-id'] = footnoteAnchor(attributes, footnoteId); } final id = attributes['id']; final candidate = block.id == 'writerside-document-title' diff --git a/lib/src/writerside/writerside_document.dart b/lib/src/writerside/writerside_document.dart index 5acb72fe..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 diff --git a/lib/src/writerside/writerside_document_renderer.dart b/lib/src/writerside/writerside_document_renderer.dart index e993885d..e4109c5c 100644 --- a/lib/src/writerside/writerside_document_renderer.dart +++ b/lib/src/writerside/writerside_document_renderer.dart @@ -1203,6 +1203,7 @@ class _WritersideRenderSession { if (element.provenance case final provenance?) ...{ writersideSourceModuleRootAttribute: provenance.moduleRoot, writersideSourceTopicPathAttribute: provenance.topicPath, + writersideSourceOccurrenceAttribute: '${provenance.occurrence}', }, if (element is WritersideGenericElementNode) 'schemaKnown': '${element.schemaKnown}', @@ -1235,6 +1236,7 @@ class _WritersideRenderSession { ...inline.attributes, writersideSourceModuleRootAttribute: provenance.moduleRoot, writersideSourceTopicPathAttribute: provenance.topicPath, + writersideSourceOccurrenceAttribute: '${provenance.occurrence}', }, children: inline.children.map(annotateInline).toList(growable: false), ); @@ -1244,6 +1246,7 @@ class _WritersideRenderSession { ...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 eb2504bb..65ba8f72 100644 --- a/lib/src/writerside/writerside_document_resolver.dart +++ b/lib/src/writerside/writerside_document_resolver.dart @@ -86,6 +86,7 @@ class _ResolveState { final List diagnostics = []; final Set _unresolvedVariables = {}; final Map> _footnoteTargets = {}; + var _nextSourceOccurrence = 0; Set _markdownFootnoteTargets(WritersideTopic topic) => _footnoteTargets.putIfAbsent(topic.filePath, () { @@ -133,6 +134,7 @@ class _ResolveState { required Set includeStack, required bool inheritedIgnoreVariables, Map arguments = const {}, + int sourceOccurrence = 0, }) { final scopedVariables = {...variables}; for (final node in nodes.whereType()) { @@ -159,6 +161,7 @@ class _ResolveState { final provenance = WritersideSourceProvenance( moduleRoot: module.rootPath, topicPath: topic.filePath, + occurrence: sourceOccurrence, ); final markdownChapters = <(int, bool)>[]; for (final node in nodes) { @@ -299,6 +302,7 @@ class _ResolveState { activeFilters: activeFilters, includeStack: includeStack, inheritedIgnoreVariables: ignoreVariables, + sourceOccurrence: sourceOccurrence, ); if (element.semanticKind == WritersideSemanticKind.api) { final reference = attributes['openapi-path'] ?? ''; @@ -791,6 +795,7 @@ class _ResolveState { topic: targetTopic, variables: includeVariables, arguments: arguments, + sourceOccurrence: ++_nextSourceOccurrence, activeFilters: filters.isEmpty ? null : filters, includeStack: includeStack, inheritedIgnoreVariables: inheritedIgnoreVariables, @@ -908,15 +913,25 @@ class _ResolveState { // 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 (_markdownFootnoteTargets( - topic, - ).contains(Uri.decodeComponent(destination.substring(1)))) { - return resolved; + 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 ?? ''}, diff --git a/test/src/export_review_regression_test.dart b/test/src/export_review_regression_test.dart index ba76c2e4..55a4b33c 100644 --- a/test/src/export_review_regression_test.dart +++ b/test/src/export_review_regression_test.dart @@ -67,6 +67,86 @@ void main() { 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(), ); @@ -531,3 +611,12 @@ class _DiagramRenderer implements VisualizationRenderer { ); } } + +Iterable _exportBlocks( + Iterable values, +) sync* { + for (final block in values) { + yield block; + yield* _exportBlocks(block.children); + } +} 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); + } +} From 2677b1a4598c9a1d8166cea9d5f31980f88fb78f Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 13 Sep 2026 14:48:15 -0700 Subject: [PATCH 13/19] Enhance asset ingestion and clipboard handling: - Add support for video ingestion alongside images in asset ingestion service. - Introduce `IngestedAssetSnapshot` to retain immutable asset reads. - Refactor and expand byte validation, ingestion, and publishing logic. - Improve clipboard capture handling for text and rich content. - Preserve functionality while switching tabs during async operations. - Ignore text-editing keys during list navigation in clipboard panel. --- lib/src/assets/asset_ingestion_service.dart | 137 ++++++++++++--- .../clipboard_history_controller.dart | 10 +- .../clipboard/clipboard_history_panel.dart | 17 +- lib/src/editor/source/source_editor.dart | 19 +- .../wysiwyg/wysiwyg_clipboard_fragment.dart | 63 +++++-- lib/src/editor/wysiwyg/wysiwyg_editor.dart | 88 ++++++---- test/src/asset_ingestion_service_test.dart | 84 +++++++++ test/src/clipboard_history_panel_test.dart | 123 ++++++++++++- test/src/source_editor_widget_test.dart | 84 +++++++++ test/src/wysiwyg_clipboard_test.dart | 162 +++++++++++++++++- 10 files changed, 706 insertions(+), 81 deletions(-) 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/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/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/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/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: '