mirror of
https://github.com/anthonyfitzpatrick/manuscript-compiler.git
synced 2026-07-22 08:31:19 +00:00
Add Markdown export and refine formatting controls
This commit is contained in:
parent
5021ff8fb6
commit
117aaf2144
51 changed files with 5287 additions and 506 deletions
34
.github/workflows/ci.yml
vendored
Normal file
34
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
- run: npm run test:docx
|
||||
- run: npm run test:odt
|
||||
- run: npm run test:epub
|
||||
- run: npm run test:html
|
||||
- run: npm run test:xml
|
||||
- run: npm run test:exports
|
||||
- run: npm run benchmark:large
|
||||
- run: npm run build
|
||||
- run: npm run package
|
||||
- run: npm run package:validate
|
||||
|
|
@ -33,7 +33,7 @@ Changing a filename or export format does not invalidate or reconstruct the prep
|
|||
- `workspace/compile-workspace-controller.ts` owns three-stage state, invalidation, cancellation, and duplicate-action suppression.
|
||||
- Workspace step modules render DOM controls only.
|
||||
- `semantic-document.ts` creates one format-independent block/section projection.
|
||||
- `native-exporters.ts` contains DOCX, ODT, PDF, EPUB, HTML, and XML byte generators.
|
||||
- `native-exporters.ts` contains DOCX, ODT, EPUB, HTML, Markdown, and XML byte generators.
|
||||
- `export-validators.ts` is the validator registry used before delivery.
|
||||
- `export-coordinator.ts` verifies the session, generates, validates, starts one download, and records history.
|
||||
- `browser-download.ts` owns the complete Blob/object-URL/temporary-anchor lifecycle.
|
||||
|
|
@ -43,7 +43,7 @@ No exporter accepts `ScannedBook`, a vault, a parser, or raw note paths. Exporte
|
|||
|
||||
## Export contracts
|
||||
|
||||
`ExportFormat` is `docx | odt | pdf | epub | html | xml`. Each exporter returns `{ format, filename, mimeType, bytes, warnings }`. `EXPORTERS` and `EXPORT_VALIDATORS` are exhaustive registries keyed by that union.
|
||||
`ExportFormat` is `docx | odt | epub | html | markdown | xml`. Each exporter returns `{ format, filename, mimeType, bytes, warnings }`. `EXPORTERS` and `EXPORT_VALIDATORS` are exhaustive registries keyed by that union.
|
||||
|
||||
The coordinator order is fixed:
|
||||
|
||||
|
|
@ -59,9 +59,11 @@ Download-started is not filesystem-persisted. No final external path, Downloads
|
|||
|
||||
## Format implementation
|
||||
|
||||
DOCX retains the native WordprocessingML generator and structural validator. ODT and EPUB use `fflate` with the required first, uncompressed `mimetype` entry and controlled package paths. HTML and XML use UTF-8 `TextEncoder` output. PDF is generated internally as direct binary PDF objects, pages, content streams, xref, trailer, and EOF marker; it uses no runtime PDF dependency. PDF display codes use the built-in font's WinAnsi encoding, while a deterministic ToUnicode CMap maps every emitted byte back to Unicode for selection, search, and copy. Input is normalised to NFC; unsupported characters receive an intentional `?` glyph and one grouped informational issue rather than corrupting adjacent text.
|
||||
DOCX retains the native WordprocessingML generator and structural validator. ODT and EPUB use `fflate` with the required first, uncompressed `mimetype` entry and controlled package paths. HTML, Markdown, and XML use UTF-8 `TextEncoder` output. Markdown renders deterministically from the same `SemanticDocument`, preserving publication structure, emphasis, readable links, Unicode, and canonical paragraph spacing.
|
||||
|
||||
The PDF layout pass uses one centimetre/millimetre-to-point conversion, exact A4 or Letter dimensions, and `page width - left margin - right margin` as its text measure. Wrapping uses the emitted Times-Roman or Helvetica glyph widths in their 1000-unit coordinate system. Semantic blocks retain their own alignment, leading, spacing, keep-with-next rules, and first-line indentation; paragraph continuations use the full measure. Part/Chapter heading groups and scene breaks are kept with following content where space permits. Generated page dictionaries carry bounded layout measurements so the validator can reject implausibly narrow columns and out-of-bounds text without screenshot analysis.
|
||||
The non-DOCX presentation renderers consume the projection's existing heading styles without reinterpreting the Book. ODT heading paragraphs reference explicit bold named styles. HTML and EPUB combine adjacent Part number/title and Chapter number/title blocks into semantic heading elements, while number-only and title-only modes retain distinct manuscript heading classes; every structural class resolves to `font-weight: 700` without a generic heading-weight rule. Markdown emits unescaped `#`, `##`, and `###` syntax without redundant emphasis, so raw source is not expected to look bold until rendered. The DOCX path is independent of these presentation rules and is deliberately unchanged.
|
||||
|
||||
`indentParagraphs` is part of the shared export formatting options and therefore of the prepared-input signature; changing it invalidates the prepared preview without rescanning or rebuilding the semantic Book. The semantic projection continues to classify first paragraphs and later body paragraphs independently of presentation. DOCX and ODT map the resolved boolean and existing indent size to their Body Text styles, while HTML and EPUB map them to `.body-text`; First Paragraph remains zero-indent in every case, including after headings and scene breaks. Markdown deliberately ignores visual indentation preferences to preserve portable source, and XML carries no presentation preference. Missing persisted values migrate idempotently to `true`, preserving historical output and existing indent sizes; Vellum and Standard Manuscript also default to enabled, while Custom retains the current value.
|
||||
|
||||
All ZIP entry names are constants or generated section filenames under controlled prefixes. User-controlled filenames never become ZIP paths. XML 1.0-invalid characters are removed, element text and attributes are escaped, HTML is escaped, and CSS font names are constrained before interpolation.
|
||||
|
||||
|
|
@ -78,6 +80,8 @@ The XML interchange structure uses:
|
|||
|
||||
Inline emphasis is represented with `span` elements carrying `bold` and/or `italic`. The schema deliberately excludes source paths, YAML, profile identifiers, settings, diagnostics, and compiler state.
|
||||
|
||||
XML consumers should render semantic structural `heading` elements in bold. The interchange document does not add presentation elements or styling attributes to those headings.
|
||||
|
||||
## Output delivery and compatibility data
|
||||
|
||||
Completed exports exist in memory until `BrowserDownloadService` constructs a Blob. The service assigns anchor `href` and `download` as DOM properties, appends the anchor, clicks once, removes it, and revokes the object URL on both success and failure. There is no Electron, Node filesystem, or vault binary-write path.
|
||||
|
|
@ -102,6 +106,6 @@ The explicit diagnostics command may write its redacted Markdown report through
|
|||
- `tests/run.ts`: parsing, cleaning, inference, migration, state, privacy, UI view models, route identity, and repository hygiene.
|
||||
- `tests/docx-integration.ts`: semantic Word XML and package regression coverage.
|
||||
- `tests/exports.ts`: all exporter registries, formats, validators, escaping, forbidden-content checks, filenames, and browser-download cleanup.
|
||||
- `tests/large-manuscript-benchmark.ts`: prepares one large Book/SemanticDocument, then measures all six generators independently with a generous runaway guard.
|
||||
- `tests/large-manuscript-benchmark.ts`: prepares one large Book/SemanticDocument, then measures all six generators with indentation enabled and disabled without repeating manuscript preparation, under a generous runaway guard.
|
||||
|
||||
Automated package tests do not establish application interoperability. Live gates remain unchecked in `MANUAL_TESTING.md` until performed.
|
||||
|
|
|
|||
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -4,18 +4,21 @@
|
|||
|
||||
### Universal export workflow
|
||||
|
||||
- Added native DOCX, ODT, PDF, EPUB 3, standalone HTML, and versioned manuscript XML exporters over one shared projection of the prepared semantic Book.
|
||||
- Added native DOCX, ODT, EPUB 3, standalone HTML, Markdown, and versioned manuscript XML exporters over one shared projection of the prepared semantic Book.
|
||||
- Added one validator registry and blocked browser delivery whenever the selected format fails structural validation.
|
||||
- Replaced every active vault/external-path output route with one platform-neutral Blob download service that removes temporary anchors and always revokes object URLs.
|
||||
- Removed completed-export vault writes, output folders, overwrite/open/reveal actions, Electron compatibility code, and the obsolete staged vault writer. Historical fields remain migration-only.
|
||||
- Added portable cross-format filename repair, accurate MIME types, per-format progressive formatting controls, format/download history flags, individual format test commands, and a six-format large-manuscript benchmark.
|
||||
- Added native ODT and EPUB ZIP structures using the existing `fflate` runtime dependency; HTML and XML use built-in UTF-8 encoding; PDF is generated internally without another package or executable.
|
||||
- Added native ODT and EPUB ZIP structures using the existing `fflate` runtime dependency; HTML, Markdown, and XML use built-in UTF-8 encoding.
|
||||
- Kept version 0.9.2 and preserved the existing manuscript detection, correction, cleaning, prepared-session, Vellum DOCX, privacy, and stale-preview behaviour.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Repaired native PDF text rendering: the generator no longer sends Unicode code units through an unembedded identity glyph map. It now emits deterministic WinAnsi glyph codes with an exact ToUnicode map, binary-safe stream lengths and xref offsets, and one informational fallback for unsupported characters.
|
||||
- Replaced the PDF generator's fixed-width `0.52em` wrapping estimate with the emitted Times-Roman/Helvetica 1000-unit glyph metrics, corrected A4 point dimensions, full-width margin math, block-aware pagination, one-line paragraph indentation, centred headings/scene breaks, and explicit Part/Chapter spacing.
|
||||
- Added the official Obsidian ESLint flat configuration, sentence-case command labels, supported lifecycle/DOM usage, strict TypeScript, scoped theme-safe CSS, and release pre-flight validation without changing command IDs or export semantics.
|
||||
- Traced the reported normal-weight, combined Part/Chapter lines to valid Markdown heading source being viewed as plain text. Markdown remains clean `#`/`##` syntax; rendered Markdown supplies its heading presentation.
|
||||
- Made HTML and EPUB Part/Chapter output use explicit combined or separate manuscript heading classes whose embedded stylesheet sets `font-weight: 700`, with no generic heading-weight rule and no later weight reset.
|
||||
- Verified ODT heading paragraphs reference named styles that explicitly declare bold while Author, body paragraphs, and scene separators retain their existing weight. XML remains semantic and presentation-neutral.
|
||||
- Left the native DOCX generator and its Vellum behaviour unchanged. This correction adds no runtime dependency, external tool, network route, or conversion step.
|
||||
- Enlarged Contents folder disclosure chevrons to 21 px with a 30 × 30 px native-button target without changing note markers, ordering arrows, keyboard operation, focus, or ARIA state.
|
||||
- Rejected `.` and `..` output segments before adapter path normalisation so traversal cannot be collapsed into an apparently safe vault path.
|
||||
- Changed stale-preview verification to hash source contents, detecting equal-size edits even when file timestamps do not change.
|
||||
|
|
@ -48,6 +51,8 @@
|
|||
|
||||
### Improved
|
||||
|
||||
- Added a persistent **Indent first line of paragraphs** toggle for DOCX, ODT, EPUB, and HTML. Enabled output applies the existing indent size only to later body paragraphs; disabled output uses zero first-line indent while first paragraphs after headings and scene breaks remain zero in both modes. The size control is hidden when disabled, Markdown reports its portable-format limitation without changing output, and XML remains presentation-neutral.
|
||||
- Missing indentation-toggle settings migrate idempotently to enabled so existing output is preserved. Vellum and Standard Manuscript default to enabled, Custom retains its toggle and size, and the option uses the existing prepared-session/export path without a new dependency or executable.
|
||||
- Simplified the authoritative workspace to **Manuscript → Contents → Create file**, with a compact collapsed outline, focused ignored/warning reviews, and full item controls retained behind **Correct structure**.
|
||||
- Combined format selection, applicable formatting, warnings, resolved filename, and readiness in the final Create file stage; advanced formatting, templates, profiles, and records remain available through disclosures.
|
||||
- Added conservative metadata/folder title resolution, author fallback, privacy-safe warning categories, and platform-specific alternate-save wording without adding another compile or parse path.
|
||||
|
|
|
|||
14
CONTRIBUTING.md
Normal file
14
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Contributing
|
||||
|
||||
Use Node.js 24 and install the locked development dependencies with `npm ci`.
|
||||
|
||||
- `npm run dev` watches the production source and rebuilds `main.js` for local testing.
|
||||
- `npm run typecheck` checks strict TypeScript.
|
||||
- `npm run lint` runs the official Obsidian ESLint configuration.
|
||||
- `npm test` runs the main regression suite.
|
||||
- `npm run test:exports` runs all native-export and download tests.
|
||||
- `npm run build` creates a minified production bundle without a source map.
|
||||
- `npm run package` creates the optional local ZIP.
|
||||
- `npm run package:validate` verifies the release allowlist and individual GitHub assets.
|
||||
|
||||
Behavior changes need focused regression coverage. A release tag must exactly equal the version in `manifest.json` without a `v` prefix, and the GitHub release must attach `main.js`, `manifest.json`, and `styles.css` individually.
|
||||
|
|
@ -42,35 +42,41 @@ All items are intentionally unchecked. Record date, tester, Obsidian version, op
|
|||
- [ ] Open DOCX in LibreOffice and inspect the same structure.
|
||||
- [ ] Import DOCX into Vellum and confirm Parts/Chapters are recognised without duplicate titles.
|
||||
- [ ] Exercise Vellum, Standard Manuscript, Custom, A4/Letter, title page, TOC, Unicode, and every scene-break choice.
|
||||
- [ ] Export DOCX with **Indent first line of paragraphs** on and off; confirm later Body Text follows the toggle and configured size while First Paragraph, headings, and scene breaks remain zero-indent or otherwise unchanged.
|
||||
- [ ] With indentation enabled, confirm the first paragraph after a Chapter heading and after a scene break remains unindented.
|
||||
|
||||
## ODT
|
||||
|
||||
- [ ] Open ODT in LibreOffice and inspect styles, page starts, title/matter order, paragraphs, emphasis, Unicode, and scene breaks.
|
||||
- [ ] Confirm title, front/back matter, Part number/title, and Chapter number/title paragraphs are visibly bold; confirm Author, body prose, and scene separators retain their existing normal weight.
|
||||
- [ ] Confirm A4/Letter and Standard Manuscript formatting behave as selected.
|
||||
|
||||
## PDF
|
||||
|
||||
- [ ] Open PDF in at least two independent viewers.
|
||||
- [ ] Confirm page size, wrapping, page flow, Parts/Chapters, matter, and scene breaks.
|
||||
- [ ] In each viewer, render, select, copy, and search `Östersund`, `Å Ä Ö å ä ö`, curly quotes, en/em dashes, `Café, naïve, façade, déjà vu`, and `© ® ™ € £ ¥`.
|
||||
- [ ] Confirm unsupported characters use the documented `?` fallback and produce one informational item, not mojibake or repeated warnings.
|
||||
- [ ] Confirm parentheses, backslashes, long wrapped paragraphs, and page breaks do not corrupt surrounding text.
|
||||
- [ ] Confirm no blank, duplicated, substituted, or missing glyphs within the documented WinAnsi character coverage.
|
||||
- [ ] Confirm A4 uses 2.54 cm margins and body lines use the full text measure without a narrow right-side void.
|
||||
- [ ] Confirm only the first line of later body paragraphs is indented; continuation lines return to the left margin, while first prose after Chapter headings and scene breaks is unindented.
|
||||
- [ ] Inspect Part/Chapter number-title spacing, heading-to-prose spacing, centred scene breaks, continuation pages, and bottom-margin clearance.
|
||||
- [ ] Export ODT with **Indent first line of paragraphs** on and off; confirm BodyText changes while FirstParagraph, headings, and scene breaks do not.
|
||||
|
||||
## EPUB
|
||||
|
||||
- [ ] Open EPUB in at least two EPUB 3 readers.
|
||||
- [ ] Confirm navigation, spine order, title/matter/Part/Chapter order, reflow, emphasis, Unicode, and scene breaks.
|
||||
- [ ] Confirm combined and separate Part/Chapter heading modes are visibly bold while body prose and scene separators remain normal weight.
|
||||
- [ ] Export EPUB with **Indent first line of paragraphs** on and off; confirm later body paragraphs change and first paragraphs after headings and scene breaks remain unindented.
|
||||
- [ ] Run EPUBCheck separately if available and record its version/results; it is not a runtime requirement.
|
||||
|
||||
## Markdown
|
||||
|
||||
- [ ] Open Markdown in at least two text editors and confirm title, author, matter, Parts, Chapters, Scenes, emphasis, readable links, Unicode, and scene separators.
|
||||
- [ ] Inspect both source and rendered views: source must use clean, unescaped `#`/`##` heading syntax without `**`; visible bold is expected only in the rendered view.
|
||||
- [ ] Confirm there is no YAML, project metadata, dashboard content, Synopsis, Revision Notes, Part 0, or Chapter 0.
|
||||
- [ ] Confirm paragraph spacing is canonical and the file ends with exactly one newline.
|
||||
- [ ] Confirm Markdown shows the portability note, offers no indentation toggle or size control, and contains no indentation spaces, tabs, HTML, or CSS workaround.
|
||||
|
||||
## HTML and XML
|
||||
|
||||
- [ ] Open HTML offline in multiple browsers; confirm embedded styling, navigation, structure, Unicode, and no network requests.
|
||||
- [ ] Open HTML offline in multiple browsers; confirm combined and separate Part/Chapter headings are visibly bold, body prose and scene separators remain normal weight, and there are no network requests.
|
||||
- [ ] Export HTML with **Indent first line of paragraphs** on and off; confirm later body paragraphs change and first paragraphs after headings and scene breaks remain unindented.
|
||||
- [ ] With indentation off, inspect front matter and a copyright page; confirm legal text, ISBN, publisher, edition, and rights paragraphs are unindented while spacing remains readable.
|
||||
- [ ] Open XML in at least two XML-aware tools and inspect namespace, schema version, hierarchy, emphasis, escaping, and deterministic ordering.
|
||||
- [ ] Confirm XML Part, Chapter, and heading elements remain semantic and contain no CSS, HTML, Markdown markers, or presentation attributes.
|
||||
- [ ] Confirm XML contains no vault paths, YAML, settings, profile IDs, or diagnostics.
|
||||
- [ ] Confirm XML shows the presentation-neutral note, offers no indentation toggle or size control, and contains no indentation preference or presentation attributes.
|
||||
|
||||
## Privacy and independence
|
||||
|
||||
|
|
|
|||
42
README.md
42
README.md
|
|
@ -1,14 +1,20 @@
|
|||
# Manuscript Compiler
|
||||
|
||||
Manuscript Compiler 0.9.2 turns fiction in an Obsidian vault into DOCX, ODT, PDF, EPUB, standalone HTML, or structured XML. It works offline, never changes source notes, and requires neither Pandoc nor another community plugin.
|
||||
Manuscript Compiler 0.9.2 turns fiction in an Obsidian vault into DOCX, ODT, EPUB, standalone HTML, Markdown, or structured XML. It works offline, never changes source notes, and requires neither Pandoc nor another community plugin.
|
||||
|
||||
Only content included in the reviewed manuscript structure is exported. Project metadata, author notes, dashboards, and excluded notes are not manuscript content.
|
||||
|
||||
## Quick Start
|
||||
The workflow has three stages: **Manuscript → Contents → Create file**. Every format is generated locally in memory, validated, and handed to the host download/share flow. Completed exports are downloaded outside the vault; the plugin does not choose or remember the final destination.
|
||||
|
||||
## Installation
|
||||
|
||||
Until the plugin is listed in Obsidian's Community Plugins directory, download `main.js`, `manifest.json`, and `styles.css` from a GitHub release whose tag exactly matches the manifest version. Put the three files in a vault plugin folder named `manuscript-compiler`, reload Obsidian, and enable **Manuscript Compiler** under Community Plugins.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. In File Explorer, right-click the book folder and choose **Compile manuscript from this folder**.
|
||||
2. Review the detected structure and correct inclusion, roles, or order if needed.
|
||||
3. Choose DOCX, ODT, PDF, EPUB, HTML, or XML.
|
||||
3. Choose DOCX, ODT, EPUB, HTML, Markdown, or XML.
|
||||
4. Choose the formatting controls that apply to that format.
|
||||
5. Press **Create and download …**.
|
||||
6. Complete the save or share flow provided by Obsidian and the operating system.
|
||||
|
|
@ -21,12 +27,14 @@ If an included note or a semantic compile choice changes after preparation, expo
|
|||
|
||||
- **DOCX** is native WordprocessingML for Word, Vellum, editing, and submission. Vellum and Standard Manuscript presets are fully supported.
|
||||
- **ODT** is a native OpenDocument Text ZIP package for LibreOffice and compatible editors.
|
||||
- **PDF** is a directly generated, fixed-layout A4 or Letter document with searchable Unicode text, built-in serif/sans-serif choices, and configurable margins. It is not browser-print output and uses no external executable.
|
||||
- **EPUB** is a native EPUB 3 reflowable package with container, package document, navigation, XHTML sections, and embedded CSS.
|
||||
- **HTML** is one offline HTML5 file with embedded CSS, semantic sections, and no JavaScript or remote assets.
|
||||
- **Markdown** is a deterministic, portable plain-text manuscript preserving semantic structure, emphasis, readable links, Unicode, and paragraph spacing.
|
||||
- **XML** is a deterministic interchange format in the `https://manuscript-compiler.dev/schema` namespace with `schemaVersion="1.0"`. It contains manuscript content and structure, but no vault paths, profile IDs, settings, or private YAML metadata.
|
||||
|
||||
DOCX and ODT support document-style pagination controls. PDF uses fixed-layout controls. EPUB and HTML expose only meaningful reflowable controls. XML hides visual formatting controls. No visible control is intended to be inert.
|
||||
DOCX and ODT support document-style pagination controls. EPUB and HTML expose only meaningful reflowable controls. Markdown and XML expose structural content controls without print typography. No visible control is intended to be inert.
|
||||
|
||||
The Create file screen offers **Indent first line of paragraphs** for DOCX, ODT, EPUB, and HTML. When enabled, the configured first-line indent applies only to later body paragraphs; the first paragraph after a structural heading or scene break remains unindented. When disabled, all body paragraphs—including copyright and other matter text—use zero first-line indent without changing paragraph or line spacing. The indent-size control is shown only while indentation is enabled. Markdown stays portable and does not simulate indentation with spaces, tabs, HTML, or CSS. XML stays presentation-neutral, so its consuming application controls paragraph indentation.
|
||||
|
||||
## Discovery and Cleaning
|
||||
|
||||
|
|
@ -46,11 +54,25 @@ Completed manuscript exports are never written into the vault. Historical vault-
|
|||
|
||||
The plugin has no Electron bridge, Node filesystem export path, network requests, telemetry, cloud service, remote assets, shell command, or external executable. `fflate` is the sole bundled runtime dependency and supplies ZIP creation/inspection.
|
||||
|
||||
### Disclosures
|
||||
|
||||
| Topic | Disclosure |
|
||||
| --- | --- |
|
||||
| External file access | Browser/host-controlled download only; the host chooses the destination. |
|
||||
| Network | None. Export, validation, and delivery initiation are offline. |
|
||||
| Accounts | None. |
|
||||
| Payments and advertising | None. |
|
||||
| Telemetry and analytics | None. |
|
||||
| Closed-source components | None. |
|
||||
| Runtime dependencies | `fflate` 0.8.3, bundled under its MIT licence. |
|
||||
| Vault writes | No manuscript export is written to the vault. The explicit diagnostics action can create a redacted Markdown support note. |
|
||||
| Mobile | The same browser download mechanism is attempted. Some mobile hosts may block or redirect downloads; no vault fallback is used. |
|
||||
|
||||
History records bounded structural facts: time, title, format, filename, counts, generation/validation status, and whether download dispatch started. It does not record prose, Blob URLs, absolute paths, profile IDs, or warning details containing manuscript data.
|
||||
|
||||
## DOCX Presets
|
||||
|
||||
Vellum defaults to Garamond 12 pt, 1.15 spacing, 0.75 cm first-line indent, A4, `#` scene breaks, separate Part/Chapter number and title styles, and Chapter page starts. Standard Manuscript defaults to Times New Roman 12 pt, double spacing, 1.27 cm indent, A4, `* * *` scene breaks, and Chapter page starts. Custom retains the applicable exposed values.
|
||||
Vellum defaults to Garamond 12 pt, 1.15 spacing, enabled 0.75 cm first-line indentation, A4, `#` scene breaks, separate Part/Chapter number and title styles, and Chapter page starts. Standard Manuscript defaults to Times New Roman 12 pt, double spacing, enabled 1.27 cm indentation, A4, `* * *` scene breaks, and Chapter page starts. Custom retains both the indentation toggle and its configured size along with the other applicable exposed values.
|
||||
|
||||
DOCX includes native Title, Author, Front Matter Heading, Back Matter Heading, Part Number, Part Title, Chapter Number, Chapter Title, First Paragraph, Body Text, and Scene Break styles. No Part 0 or Chapter 0 is invented.
|
||||
|
||||
|
|
@ -59,12 +81,13 @@ DOCX includes native Title, Author, Front Matter Heading, Back Matter Heading, P
|
|||
```bash
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm test
|
||||
npm run test:docx
|
||||
npm run test:odt
|
||||
npm run test:pdf
|
||||
npm run test:epub
|
||||
npm run test:html
|
||||
npm run test:markdown
|
||||
npm run test:xml
|
||||
npm run test:exports
|
||||
npm run benchmark:large
|
||||
|
|
@ -72,16 +95,17 @@ npm run build
|
|||
npm run package
|
||||
npm run package:validate
|
||||
npm audit
|
||||
git diff --check
|
||||
```
|
||||
|
||||
The release archive is `release/manuscript-compiler-0.9.2.zip` and contains exactly `main.js`, `manifest.json`, and `styles.css`.
|
||||
|
||||
Automated structural validation is not a substitute for opening outputs in Word, Vellum, LibreOffice, multiple PDF/EPUB readers, and browsers. See [MANUAL_TESTING.md](MANUAL_TESTING.md).
|
||||
Automated structural validation is not a substitute for opening outputs in Word, Vellum, LibreOffice, multiple EPUB readers, text editors, and browsers. See [MANUAL_TESTING.md](MANUAL_TESTING.md).
|
||||
|
||||
## Known Limits
|
||||
|
||||
- Browser/host download behaviour and prompts differ by platform, and the plugin cannot verify the final filesystem copy after dispatch.
|
||||
- The internal PDF validator checks structure, pages, text operators, and termination; it does not claim comprehensive PDF standards conformance.
|
||||
- On mobile, the host may block the download or route it through a platform share sheet. The plugin reports dispatch failure and does not write a fallback copy into the vault.
|
||||
- EPUB validation is structural and does not replace EPUBCheck or live reader testing.
|
||||
- Vellum import semantics require live Vellum testing.
|
||||
- Unusual authoring templates may require manual structure correction or body-heading aliases.
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ Version 0.9.2 remains a prerelease candidate. Automated gates cover the shared p
|
|||
|
||||
- Select the exact book root through File Explorer, command palette, or settings.
|
||||
- Review the compact outline and use Correct structure only when needed.
|
||||
- Choose DOCX, ODT, PDF, EPUB, HTML, or XML.
|
||||
- Choose DOCX, ODT, EPUB, HTML, Markdown, or XML.
|
||||
- Use only the formatting controls meaningful for that format.
|
||||
- For DOCX, ODT, EPUB, or HTML, choose whether later body paragraphs use the configured first-line indent; first paragraphs after headings and scene breaks remain unindented. Markdown exposes a portability note instead, and XML delegates presentation to its consumer.
|
||||
- Generate and validate bytes in memory, then start the host browser download/share flow.
|
||||
|
||||
No completed export is written into the Obsidian vault. There is no vault fallback, Electron path, external executable, network request, telemetry, or dependency on another community plugin. The host controls the final destination and the plugin cannot verify that external filesystem copy after download dispatch.
|
||||
|
|
@ -19,12 +20,13 @@ No completed export is written into the Obsidian vault. There is no vault fallba
|
|||
Run and record current output before publishing:
|
||||
|
||||
- `npm run typecheck`
|
||||
- `npm run lint`
|
||||
- `npm test`
|
||||
- `npm run test:docx`
|
||||
- `npm run test:odt`
|
||||
- `npm run test:pdf`
|
||||
- `npm run test:epub`
|
||||
- `npm run test:html`
|
||||
- `npm run test:markdown`
|
||||
- `npm run test:xml`
|
||||
- `npm run test:exports`
|
||||
- `npm run benchmark:large`
|
||||
|
|
@ -36,20 +38,21 @@ Run and record current output before publishing:
|
|||
|
||||
The release archive must be `release/manuscript-compiler-0.9.2.zip` and contain exactly `main.js`, `manifest.json`, and `styles.css`.
|
||||
|
||||
Attach those three files individually to the GitHub release. The release tag must be exactly `0.9.2`, without a `v` prefix; the ZIP is optional and is not an installation dependency.
|
||||
|
||||
## Runtime dependencies
|
||||
|
||||
Obsidian supplies the host API. `fflate` is the sole bundled runtime package and is used for DOCX, ODT, and EPUB ZIP generation plus structural inspection. PDF, HTML, XML, filename handling, and download delivery add no runtime package.
|
||||
Obsidian supplies the host API. `fflate` is the sole bundled runtime package and is used for DOCX, ODT, and EPUB ZIP generation plus structural inspection. HTML, Markdown, XML, filename handling, and download delivery add no runtime package.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Browser/host save prompts differ across Windows, macOS, Linux, and mobile.
|
||||
- Download dispatch is observable; final external persistence is not.
|
||||
- Internal PDF validation is structural, not a claim of full standards conformance.
|
||||
- Native PDF uses the built-in WinAnsi font repertoire with NFC normalisation and an exact ToUnicode map. Characters outside that repertoire use an intentional `?` fallback reported once as information; live viewer checks remain required.
|
||||
- Native PDF wrapping uses built-in Times-Roman/Helvetica metrics and verified A4/Letter point geometry. Automated layout checks do not replace visual inspection in Preview, Acrobat, browser, and Linux PDF viewers.
|
||||
- EPUB structural tests do not replace EPUBCheck and reader interoperability.
|
||||
- Word, LibreOffice, Vellum, PDF viewers, EPUB readers, browsers, and XML tools may expose application-specific behaviour not visible to byte-level tests.
|
||||
- Word, LibreOffice, Vellum, EPUB readers, text editors, browsers, and XML tools may expose application-specific behaviour not visible to byte-level tests.
|
||||
- Complex Markdown layout and embedded media remain outside the fiction-manuscript model.
|
||||
- Raw Markdown source displays heading markers, not rendered bold text; verify heading appearance in a Markdown rendering view.
|
||||
- Markdown has no portable first-line indentation setting, and XML intentionally contains no presentation preference; their Create file notes make these limits explicit.
|
||||
|
||||
## Manual blockers before 1.0
|
||||
|
||||
|
|
@ -57,7 +60,8 @@ Obsidian supplies the host API. `fflate` is the sole bundled runtime package and
|
|||
- Test download delivery on Windows, macOS, Linux, and mobile.
|
||||
- Inspect DOCX in Word/LibreOffice and import it into Vellum.
|
||||
- Inspect ODT in LibreOffice.
|
||||
- Inspect PDF and EPUB in at least two independent applications each.
|
||||
- Inspect standalone HTML offline in multiple browsers and XML in XML-aware tools.
|
||||
- Inspect DOCX, ODT, EPUB, and HTML with **Indent first line of paragraphs** enabled and disabled, including first paragraphs after Chapter headings and scene breaks plus a copyright/front-matter page with indentation disabled.
|
||||
- Inspect bold structural headings and normal-weight prose in ODT and in at least two independent EPUB applications.
|
||||
- Inspect bold structural headings and normal-weight prose in standalone HTML offline in multiple browsers, rendered Markdown in a Markdown viewer, and presentation-neutral XML in XML-aware tools.
|
||||
- Test a large real-world Unicode manuscript and confirm no vault output or Blob URL leak.
|
||||
- Resolve discovered defects and repeat every automated gate.
|
||||
|
|
|
|||
13
THIRD_PARTY_NOTICES.md
Normal file
13
THIRD_PARTY_NOTICES.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Third-party notices
|
||||
|
||||
## fflate 0.8.3
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Arjun Barrett
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
|
@ -3,7 +3,15 @@ import process from "process";
|
|||
import { builtinModules } from "node:module";
|
||||
|
||||
const production = process.argv[2] === "production";
|
||||
const banner = `/* Generated bundle. Source: https://github.com/anthonyfitzpatrick/manuscript-compiler */
|
||||
/*! fflate 0.8.3 — MIT License
|
||||
Copyright (c) 2026 Arjun Barrett
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/`;
|
||||
const context = await esbuild.context({
|
||||
banner: { js: banner },
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: ["obsidian", "electron", "@codemirror/autocomplete", "@codemirror/collab", "@codemirror/commands", "@codemirror/language", "@codemirror/lint", "@codemirror/search", "@codemirror/state", "@codemirror/view", "@lezer/common", "@lezer/highlight", "@lezer/lr", ...builtinModules, ...builtinModules.map((module) => `node:${module}`)],
|
||||
|
|
@ -12,6 +20,7 @@ const context = await esbuild.context({
|
|||
logLevel: "info",
|
||||
sourcemap: production ? false : "inline",
|
||||
treeShaking: true,
|
||||
minify: production,
|
||||
outfile: "main.js"
|
||||
});
|
||||
|
||||
|
|
|
|||
43
eslint.config.mjs
Normal file
43
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import tsparser from "@typescript-eslint/parser";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
|
||||
export default defineConfig(
|
||||
globalIgnores([
|
||||
"node_modules",
|
||||
".test-build",
|
||||
"release",
|
||||
"tests",
|
||||
"main.js",
|
||||
"esbuild.*.mjs",
|
||||
"scripts",
|
||||
]),
|
||||
...obsidianmd.configs.recommended,
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: ["tests/*.ts"],
|
||||
},
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"obsidianmd/ui/sentence-case": ["warn", {
|
||||
acronyms: ["DOCX", "ODT", "EPUB", "HTML", "XML", "JSON", "YAML", "A4"],
|
||||
brands: ["Manuscript Compiler", "File Explorer", "Obsidian", "Vellum", "LibreOffice", "Markdown", "Garamond", "Arial", "Georgia", "Times New Roman", "centimetres"],
|
||||
enforceCamelCaseLower: true,
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/ui.ts"],
|
||||
rules: {
|
||||
// Obsidian <1.13 requires display() and lacks setDestructive().
|
||||
"@typescript-eslint/no-deprecated": "off",
|
||||
"obsidianmd/settings-tab/prefer-setting-definitions": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
"name": "Manuscript Compiler",
|
||||
"version": "0.9.2",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Compile publishable manuscript content to native DOCX, ODT, PDF, EPUB, HTML, or XML files.",
|
||||
"description": "Compile publishable manuscript content to DOCX, ODT, EPUB, HTML, Markdown, or XML files.",
|
||||
"author": "Anthony Fitzpatrick",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
|
|
|
|||
4609
package-lock.json
generated
4609
package-lock.json
generated
File diff suppressed because it is too large
Load diff
14
package.json
14
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "obsidian-manuscript-compiler",
|
||||
"version": "0.9.2",
|
||||
"description": "Validate, preview, and compile Obsidian book manuscripts to DOCX, ODT, PDF, EPUB, HTML, or XML.",
|
||||
"description": "Validate, preview, and compile Obsidian book manuscripts to DOCX, ODT, EPUB, HTML, Markdown, or XML.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
|
|
@ -10,12 +10,15 @@
|
|||
"test": "node esbuild.test.mjs",
|
||||
"test:docx": "node esbuild.docx-test.mjs",
|
||||
"test:odt": "node esbuild.exports-test.mjs odt",
|
||||
"test:pdf": "node esbuild.exports-test.mjs pdf",
|
||||
"test:epub": "node esbuild.exports-test.mjs epub",
|
||||
"test:html": "node esbuild.exports-test.mjs html",
|
||||
"test:markdown": "node esbuild.exports-test.mjs markdown",
|
||||
"test:xml": "node esbuild.exports-test.mjs xml",
|
||||
"test:exports": "node esbuild.exports-test.mjs",
|
||||
"benchmark:large": "node esbuild.benchmark.mjs",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"lint:fix": "eslint src manifest.json LICENSE --fix --max-warnings 0",
|
||||
"check": "npm run typecheck && npm run lint && npm test && npm run test:exports",
|
||||
"package": "npm run typecheck && npm test && npm run test:docx && npm run test:exports && npm run build && node scripts/package.mjs",
|
||||
"package:validate": "node scripts/package.mjs --validate"
|
||||
},
|
||||
|
|
@ -26,11 +29,16 @@
|
|||
"docx",
|
||||
"publishing"
|
||||
],
|
||||
"author": "Anthony Fitzpatrick",
|
||||
"repository": "https://github.com/anthonyfitzpatrick/manuscript-compiler",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/parser": "^8.64.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"esbuild": "^0.28.1",
|
||||
"obsidian": "^1.8.7",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-obsidianmd": "^0.4.1",
|
||||
"obsidian": "^1.13.1",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,69 @@
|
|||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { unzipSync, zipSync } from "fflate";
|
||||
|
||||
const packageJson = JSON.parse(await readFile("package.json", "utf8")); const manifest = JSON.parse(await readFile("manifest.json", "utf8")); const versions = JSON.parse(await readFile("versions.json", "utf8"));
|
||||
if (packageJson.version !== manifest.version) throw new Error(`Version mismatch: package ${packageJson.version}, manifest ${manifest.version}`);
|
||||
if (versions[manifest.version] !== manifest.minAppVersion) throw new Error(`versions.json does not map ${manifest.version} to ${manifest.minAppVersion}`);
|
||||
const required = ["main.js", "manifest.json", "styles.css"]; const archivePath = path.join("release", `manuscript-compiler-${manifest.version}.zip`);
|
||||
if (process.argv.includes("--validate")) { const archive = await readFile(archivePath); const names = Object.keys(unzipSync(archive)); if (JSON.stringify(names.sort()) !== JSON.stringify(required.sort())) throw new Error(`Unexpected archive entries: ${names.join(", ")}`); process.stdout.write(`Validated ${archivePath}: ${names.join(", ")}\n`); process.exit(0); }
|
||||
const entries = await Promise.all(required.map(async (name) => ({ name, data: await readFile(name) }))); await rm("release", { recursive: true, force: true }); await mkdir("release", { recursive: true }); await writeFile(archivePath, createZip(entries)); process.stdout.write(`Created ${archivePath} with only ${required.join(", ")}\n`);
|
||||
const required = ["main.js", "manifest.json", "styles.css"];
|
||||
const allowedManifestKeys = new Set(["id", "name", "version", "minAppVersion", "description", "author", "authorUrl", "fundingUrl", "isDesktopOnly"]);
|
||||
const semverPattern = /^\d+\.\d+\.\d+$/;
|
||||
const packageJson = await jsonFile("package.json");
|
||||
const manifest = await jsonFile("manifest.json");
|
||||
const versions = await jsonFile("versions.json");
|
||||
const archivePath = path.join("release", `manuscript-compiler-${manifest.version}.zip`);
|
||||
|
||||
function createZip(entries) { return zipSync(Object.fromEntries(entries.map((entry) => [entry.name, entry.data])), { level: 9 }); }
|
||||
validateManifest();
|
||||
validateVersions();
|
||||
await validateAssets();
|
||||
|
||||
if (process.argv.includes("--validate")) {
|
||||
const archive = await readFile(archivePath);
|
||||
const names = Object.keys(unzipSync(archive)).sort();
|
||||
assert(JSON.stringify(names) === JSON.stringify([...required].sort()), `Unexpected archive entries: ${names.join(", ")}`);
|
||||
process.stdout.write(`Validated ${archivePath}: ${names.join(", ")}\n`);
|
||||
process.stdout.write(`GitHub release assets ready: ${required.join(", ")} (tag ${manifest.version}, without a v prefix)\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const entries = await Promise.all(required.map(async (name) => ({ name, data: await readFile(name) })));
|
||||
await rm("release", { recursive: true, force: true });
|
||||
await mkdir("release", { recursive: true });
|
||||
await writeFile(archivePath, zipSync(Object.fromEntries(entries.map((entry) => [entry.name, entry.data])), { level: 9 }));
|
||||
process.stdout.write(`Created ${archivePath} with only ${required.join(", ")}\n`);
|
||||
process.stdout.write(`GitHub release assets ready: ${required.join(", ")} (tag ${manifest.version}, without a v prefix)\n`);
|
||||
|
||||
async function validateAssets() {
|
||||
for (const name of required) {
|
||||
const info = await stat(name);
|
||||
assert(info.isFile() && info.size > 0, `Required release asset is missing or empty: ${name}`);
|
||||
}
|
||||
const bundle = await readFile("main.js", "utf8");
|
||||
const releaseText = `${bundle}\n${await readFile("manifest.json", "utf8")}\n${await readFile("styles.css", "utf8")}`;
|
||||
assert(!/sourceMappingURL|\.map(?:\s|$)/i.test(bundle), "Production bundle contains a source map reference.");
|
||||
assert(!/require\(["'](?:electron|(?:node:)?(?:fs|path|os|child_process))["']\)/.test(bundle), "Production bundle contains a prohibited desktop or Node import.");
|
||||
assert(!/\b(?:fetch|requestUrl|XMLHttpRequest|WebSocket|sendBeacon)\s*\(/.test(bundle), "Production bundle contains a prohibited network API.");
|
||||
assert(!/\b(?:eval|Function)\s*\(/.test(bundle), "Production bundle contains dynamic code execution.");
|
||||
assert(!/https?:\/\/(?:localhost|127\.0\.0\.1|192\.168\.|10\.|172\.(?:1[6-9]|2\d|3[01])\.)/i.test(releaseText), "Release assets contain a local or private-network URL.");
|
||||
assert(!releaseText.includes(process.cwd()), "Release assets contain the local build path.");
|
||||
assert(bundle.includes("fflate 0.8.3 — MIT License") && bundle.includes("Copyright (c) 2026 Arjun Barrett"), "Production bundle is missing the bundled fflate licence notice.");
|
||||
}
|
||||
|
||||
function validateManifest() {
|
||||
assert(packageJson.version === manifest.version, `Version mismatch: package ${packageJson.version}, manifest ${manifest.version}`);
|
||||
assert(Object.keys(manifest).every((key) => allowedManifestKeys.has(key)), "manifest.json contains unsupported fields.");
|
||||
assert(typeof manifest.id === "string" && /^[a-z]+(?:-[a-z]+)*$/.test(manifest.id) && !manifest.id.includes("obsidian") && !manifest.id.endsWith("plugin"), "Manifest ID is invalid.");
|
||||
assert(path.basename(process.cwd()) === manifest.id, `Plugin folder must match manifest ID ${manifest.id}.`);
|
||||
assert(typeof manifest.name === "string" && /^[\x20-\x7E]+$/.test(manifest.name) && !/obsidian|plugin/i.test(manifest.name), "Manifest name is invalid.");
|
||||
assert(semverPattern.test(manifest.version) && semverPattern.test(manifest.minAppVersion), "Manifest versions must use x.y.z semantic versions.");
|
||||
assert(typeof manifest.description === "string" && manifest.description.trim().length > 0, "Manifest description is required.");
|
||||
assert(typeof manifest.author === "string" && manifest.author.trim().length > 0, "Manifest author is required.");
|
||||
assert(typeof manifest.isDesktopOnly === "boolean", "Manifest isDesktopOnly must be a boolean.");
|
||||
}
|
||||
|
||||
function validateVersions() {
|
||||
const entries = Object.entries(versions);
|
||||
assert(entries.length > 0, "versions.json must contain at least one minimum-version boundary.");
|
||||
for (const [pluginVersion, appVersion] of entries) assert(semverPattern.test(pluginVersion) && typeof appVersion === "string" && semverPattern.test(appVersion), "versions.json contains an invalid version.");
|
||||
assert(entries.at(-1)?.[1] === manifest.minAppVersion, `Latest versions.json boundary must match minAppVersion ${manifest.minAppVersion}.`);
|
||||
}
|
||||
|
||||
async function jsonFile(filename) { return JSON.parse(await readFile(filename, "utf8")); }
|
||||
function assert(condition, message) { if (!condition) throw new Error(message); }
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* children. Legacy commands may infer from configuration or active-note ancestry.
|
||||
* The root names the Book but is never emitted as a structural node.
|
||||
*/
|
||||
import { TFile, TFolder, type Vault } from "obsidian";
|
||||
import { normalizePath, TFile, TFolder, type Vault } from "obsidian";
|
||||
|
||||
const BOOK_STRUCTURE_PATTERN = /^(?:part\b|(?:ebook |print )?(?:front|back) matter$|manuscript$|drafts?$|chapters$)/i;
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ export class BookRootResolver {
|
|||
constructor(private readonly vault: Vault) {}
|
||||
|
||||
require(path: string, label = "manuscript folder"): TFolder {
|
||||
const folder = this.vault.getAbstractFileByPath(path);
|
||||
const folder = this.vault.getAbstractFileByPath(normalizePath(path));
|
||||
if (!(folder instanceof TFolder)) throw new Error(`The ${label} does not exist.`);
|
||||
return folder;
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ export class BookRootResolver {
|
|||
/** Legacy commands may infer a root from saved configuration or current-file ancestry. */
|
||||
configuredOrCurrent(configuredPath: string, activeFile: TFile | null): TFolder | null {
|
||||
if (configuredPath.trim()) {
|
||||
const configured = this.vault.getAbstractFileByPath(configuredPath.trim());
|
||||
const configured = this.vault.getAbstractFileByPath(normalizePath(configuredPath.trim()));
|
||||
if (configured instanceof TFolder) return configured;
|
||||
}
|
||||
let folder = activeFile?.parent ?? null;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
export interface DownloadFileRequest { filename: string; bytes: Uint8Array; mimeType: string; }
|
||||
export interface DownloadFileResult { started: boolean; filename: string; error?: string; }
|
||||
export interface DownloadEnvironment {
|
||||
createObjectURL(blob: Blob): string; revokeObjectURL(url: string): void;
|
||||
createObjectURL(blob: Blob, anchor: HTMLAnchorElement): string; revokeObjectURL(url: string, anchor: HTMLAnchorElement): void;
|
||||
createAnchor(): HTMLAnchorElement; append(anchor: HTMLAnchorElement): void;
|
||||
defer(action: () => void): void;
|
||||
defer(action: () => void, anchor: HTMLAnchorElement): void;
|
||||
}
|
||||
|
||||
export class BrowserDownloadService {
|
||||
|
|
@ -12,11 +12,12 @@ export class BrowserDownloadService {
|
|||
async download(request: DownloadFileRequest): Promise<DownloadFileResult> {
|
||||
const filename = safeLeaf(request.filename); let url = ""; let anchor: HTMLAnchorElement | undefined; let clicked = false;
|
||||
try {
|
||||
const copy = request.bytes.slice(); const blob = new Blob([copy], { type: request.mimeType }); url = this.environment.createObjectURL(blob); anchor = this.environment.createAnchor(); anchor.href = url; anchor.download = filename; anchor.style.display = "none"; this.environment.append(anchor); anchor.click(); clicked = true; return { started: true, filename };
|
||||
} catch (error) { return { started: false, filename, error: error instanceof Error ? error.message : "The host blocked the download." }; }
|
||||
finally { anchor?.remove(); if (url) { if (clicked) this.environment.defer(() => this.environment.revokeObjectURL(url)); else this.environment.revokeObjectURL(url); } }
|
||||
const copy = request.bytes.slice(); const blob = new Blob([copy], { type: request.mimeType }); anchor = this.environment.createAnchor(); url = this.environment.createObjectURL(blob, anchor); anchor.href = url; anchor.download = filename; anchor.addClass("manuscript-download-anchor"); this.environment.append(anchor); anchor.click(); clicked = true; return { started: true, filename };
|
||||
} catch { return { started: false, filename, error: "The host blocked the download. Try again or use the platform share or save controls." }; }
|
||||
finally { anchor?.remove(); if (url && anchor) { const cleanupAnchor = anchor; if (clicked) this.environment.defer(() => this.environment.revokeObjectURL(url, cleanupAnchor), cleanupAnchor); else this.environment.revokeObjectURL(url, cleanupAnchor); } }
|
||||
}
|
||||
}
|
||||
|
||||
function browserEnvironment(): DownloadEnvironment { return { createObjectURL: (blob) => URL.createObjectURL(blob), revokeObjectURL: (url) => URL.revokeObjectURL(url), createAnchor: () => document.createElement("a"), append: (anchor) => document.body.appendChild(anchor), defer: (action) => window.setTimeout(action, 1000) }; }
|
||||
function safeLeaf(value: string): string { let leaf = value.replace(/^.*[\\/]/, "").replace(/[\u0000-\u001f\u007f\\/:*?"<>|]/g, "-").trim().replace(/[. ]+$/g, ""); if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(leaf)) leaf = `_${leaf}`; return leaf || "Manuscript"; }
|
||||
function browserEnvironment(): DownloadEnvironment { return { createObjectURL: (blob) => URL.createObjectURL(blob), revokeObjectURL: (url) => URL.revokeObjectURL(url), createAnchor: () => createEl("a"), append: (anchor) => anchor.doc.body.appendChild(anchor), defer: (action, anchor) => anchor.win.setTimeout(action, 1000) }; }
|
||||
function safeLeaf(value: string): string { let leaf = replaceUnsafeFilenameCharacters(value.replace(/^.*[\\/]/, "")).trim().replace(/[. ]+$/g, ""); if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(leaf)) leaf = `_${leaf}`; return leaf || "Manuscript"; }
|
||||
function replaceUnsafeFilenameCharacters(value: string): string { return [...value].map((character) => { const code = character.charCodeAt(0); return code <= 0x1f || code === 0x7f || '\\/:*?"<>|'.includes(character) ? "-" : character; }).join(""); }
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* previews/validation, and delegates export. main.ts routes compile commands here.
|
||||
* It calls BookRootResolver, CompilePreparationService, validation, and export.
|
||||
*/
|
||||
import { Notice, TFile, TFolder, type App } from "obsidian";
|
||||
import { Notice, Platform, TFile, TFolder, type App } from "obsidian";
|
||||
import { BookRootResolver } from "./book-root-resolver";
|
||||
import { CompilationCancelledError } from "./cancellation";
|
||||
import { calculateSourceFingerprint, CompilePreparationService, type CompilePurpose, type CompileRoute, type PreparedCompileSession } from "./compile-preparation";
|
||||
|
|
@ -66,7 +66,7 @@ export class CompileCommandService {
|
|||
/** Validates the exact semantic model export would consume and never writes output. */
|
||||
async validateManuscript(): Promise<void> { try { const folder = this.currentRoot(); if (!folder) throw new Error("Set a manuscript root in the active compile profile, or open a note inside a recognisable book folder."); const session = await this.prepareAutomatic(folder, this.activeProfile(), "validation", "validation"); const result = await new ManuscriptValidationService(this.app.vault, this.settings()).validate(session); new ValidationReportModal(this.app, folder.path, result).open(); } catch (error) { showError(error); } }
|
||||
/** Builds a privacy-limited configuration report; manuscript prose is never included. */
|
||||
async generateDiagnostics(): Promise<void> { try { const report = new DiagnosticsReportGenerator().generate({ pluginVersion: this.pluginVersion, obsidianVersion: getObsidianVersion(), operatingSystem: navigator.userAgent, profile: this.activeProfile(), settings: this.settings() }); new DiagnosticsReportModal(this.app, report, () => this.saveDiagnostics(report)).open(); } catch (error) { showError(error); } }
|
||||
async generateDiagnostics(): Promise<void> { try { const report = new DiagnosticsReportGenerator().generate({ pluginVersion: this.pluginVersion, obsidianVersion: getObsidianVersion(), operatingSystem: platformName(), profile: this.activeProfile(), settings: this.settings() }); new DiagnosticsReportModal(this.app, report, () => this.saveDiagnostics(report)).open(); } catch (error) { showError(error); } }
|
||||
|
||||
private currentRoot(): TFolder | null { return this.roots.configuredOrCurrent(this.activeProfile().manuscriptRoot || this.settings().defaultManuscriptFolder, this.app.workspace.getActiveFile()); }
|
||||
private prepareAutomatic(folder: TFolder, profile: CompileProfile, purpose: CompilePurpose, route: CompileRoute, contentPlan?: ContentPlanItem[], signal?: AbortSignal): Promise<PreparedCompileSession> {
|
||||
|
|
@ -80,7 +80,8 @@ export class CompileCommandService {
|
|||
catch (error) { if (operation.signal.aborted) operation.cancel(); else operation.fail(); throw error; }
|
||||
finally { operation.settle(); externalSignal?.removeEventListener("abort", cancel); }
|
||||
}
|
||||
private async saveDiagnostics(report: string): Promise<string> { const folder = "Manuscript Compiler Diagnostics"; if (!this.app.vault.getAbstractFileByPath(folder)) await this.app.vault.createFolder(folder); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const path = `${folder}/Diagnostics ${stamp}.md`; const existing = this.app.vault.getAbstractFileByPath(path); if (existing instanceof TFile) await this.app.vault.modify(existing, report); else await this.app.vault.create(path, report); return path; }
|
||||
private async saveDiagnostics(report: string): Promise<string> { const folder = "Manuscript Compiler Diagnostics"; if (!this.app.vault.getAbstractFileByPath(folder)) await this.app.vault.createFolder(folder); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const path = `${folder}/Diagnostics ${stamp}.md`; const existing = this.app.vault.getAbstractFileByPath(path); if (existing instanceof TFile) await this.app.vault.process(existing, () => report); else await this.app.vault.create(path, report); return path; }
|
||||
}
|
||||
|
||||
function modalPromise(factory: (finish: (value: boolean) => void) => { open(): void; onClose(): void }): Promise<boolean> { return new Promise((resolve) => { let settled = false; const finish = (value: boolean): void => { if (!settled) { settled = true; resolve(value); } }; const modal = factory(finish); const close = modal.onClose.bind(modal); modal.onClose = (): void => { close(); finish(false); }; modal.open(); }); }
|
||||
function modalPromise(factory: (finish: (value: boolean) => void) => { open(): void }): Promise<boolean> { return new Promise((resolve) => { let settled = false; const finish = (value: boolean): void => { if (!settled) { settled = true; resolve(value); } }; factory(finish).open(); }); }
|
||||
function platformName(): string { if (Platform.isIosApp) return "iOS"; if (Platform.isAndroidApp) return "Android"; if (Platform.isWin) return "Windows"; if (Platform.isLinux) return "Linux"; if (Platform.isMacOS) return "macOS"; return Platform.isMobileApp ? "Mobile" : "Unknown"; }
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* CompileWorkspaceController owns state/operations; step modules own controls.
|
||||
* Parser, exporter, history, and download logic do not belong here.
|
||||
*/
|
||||
import { App, FuzzySuggestModal, Modal, Notice, TFile, TFolder } from "obsidian";
|
||||
import { App, FuzzySuggestModal, Modal, normalizePath, Notice, TFile, TFolder } from "obsidian";
|
||||
import type ManuscriptCompilerPlugin from "./main";
|
||||
import { classifyContentPlan, createContentPlan } from "./content-plan";
|
||||
import { docxFormattingForPreset, type DocxFormatting, type SimpleCompileRequest } from "./simple-workflow";
|
||||
|
|
@ -39,6 +39,7 @@ export class SimpleCompileModal extends Modal {
|
|||
const settings = plugin.settings; const profile = plugin.getActiveProfile();
|
||||
const formatting: DocxFormatting = docxFormattingForPreset(settings.defaultDocxStyle, settings.includeTitlePageByDefault);
|
||||
formatting.pageSize = settings.defaultDocxPageSize;
|
||||
formatting.indentParagraphs = settings.defaultIndentParagraphs;
|
||||
formatting.firstLineIndentCm = settings.defaultDocxFirstLineIndentCm;
|
||||
const request: SimpleCompileRequest = { manuscriptRoot: selectedFolder?.path ?? (settings.defaultManuscriptFolder || profile.manuscriptRoot), structurePreset: settings.defaultStructurePreset, includeFrontMatter: true, includeBackMatter: true, exportFolder: "", outputFilename: this.filename(profile.outputFilename || "Manuscript.docx"), outputFormat: "docx", docxPreset: settings.defaultDocxStyle, downloadAfterExport: true, formatting, tableOfContents: settings.includeTableOfContentsByDefault, partDisplay: "word-title", chapterDisplay: "word-title", custom: { variables: { ...profile.variables }, sceneSeparator: profile.sceneSeparator, bodySectionAliases: [...(profile.bodySectionAliases ?? ["Scene", "Manuscript", "Text", "Draft", "Body"])] } };
|
||||
this.controller = new CompileWorkspaceController(request, formatting, { prepare: (next, plan, signal) => this.plugin.prepareCompileRequest(next, plan, signal), sessionIsCurrent: (session) => this.plugin.preparedSessionIsCurrent(session), export: (session, format, filename) => this.plugin.exportPreparedSession(session, format, filename) });
|
||||
|
|
@ -54,7 +55,7 @@ export class SimpleCompileModal extends Modal {
|
|||
onClose(): void { this.controller.close(); this.contentEl.empty(); }
|
||||
|
||||
private render(): void {
|
||||
const state = this.controller.state; const current = steps.indexOf(state.step); this.contentEl.empty(); this.titleEl.setText("Compile Manuscript");
|
||||
const state = this.controller.state; const current = steps.indexOf(state.step); this.contentEl.empty(); this.titleEl.setText("Compile manuscript");
|
||||
const nav = this.contentEl.createDiv({ cls: "manuscript-compile-steps", attr: { role: "tablist", "aria-label": "Compile steps" } });
|
||||
labels.forEach((label, index) => { const button = nav.createEl("button", { text: `${index + 1} ${label}`, cls: index === current ? "is-active" : index < current ? "is-complete" : "" }); button.setAttribute("role", "tab"); button.setAttribute("aria-selected", String(index === current)); button.disabled = index > current + 1 || index > 0 && !state.contentPlan.length; button.addEventListener("click", () => this.enterStep(steps[index])); });
|
||||
const body = this.contentEl.createDiv({ cls: "manuscript-compile-body" });
|
||||
|
|
@ -80,15 +81,15 @@ export class SimpleCompileModal extends Modal {
|
|||
private async prepare(force = false): Promise<void> { const promise = this.controller.prepare(force); this.render(); await promise; this.render(); }
|
||||
private async export(): Promise<void> { const promise = this.controller.export(); this.controller.detachExport(); const success = await promise; if (success) this.close(); else { this.render(); if (this.controller.state.error) new Notice(this.controller.state.error.message, 8000); } }
|
||||
private async createFinalDocument(): Promise<void> { if (!this.controller.state.preparedSession) { const session = await this.controller.prepare(); this.render(); if (!session) { if (this.controller.state.error) new Notice(this.controller.state.error.message, 8000); return; } } await this.export(); }
|
||||
private folder(): TFolder | null { const item = this.app.vault.getAbstractFileByPath(this.controller.state.request.manuscriptRoot); return item instanceof TFolder ? item : null; }
|
||||
private filename(value: string): string { return `${value.replace(/\.(?:docx|odt|pdf|epub|html?|xml|md)$/i, "") || "Manuscript"}.docx`; }
|
||||
private folder(): TFolder | null { const path = this.controller.state.request.manuscriptRoot; if (!path.trim()) return null; const item = this.app.vault.getAbstractFileByPath(normalizePath(path)); return item instanceof TFolder ? item : null; }
|
||||
private filename(value: string): string { return `${value.replace(/\.(?:docx|odt|epub|html?|markdown|xml|md)$/i, "") || "Manuscript"}.docx`; }
|
||||
private updateCreateButton(): void { const button = this.contentEl.querySelector<HTMLButtonElement>(".manuscript-create-button"); if (button) button.disabled = this.controller.state.preparationStatus === "preparing" || this.controller.state.exportStatus === "exporting"; }
|
||||
private markPreviewInvalidated(): void {
|
||||
const card = this.contentEl.querySelector<HTMLElement>(".manuscript-ready-card"); if (card) { card.empty(); card.createEl("strong", { text: "Preview needs refresh" }); card.createEl("p", { text: "Preview inputs changed. Refresh the preview before creating the file." }); card.createEl("button", { text: "Refresh Preview", cls: "mod-cta" }).addEventListener("click", () => { void this.prepare(true); }); } this.updateCreateButton();
|
||||
const card = this.contentEl.querySelector<HTMLElement>(".manuscript-ready-card"); if (card) { card.empty(); card.createEl("strong", { text: "Preview needs refresh" }); card.createEl("p", { text: "Preview inputs changed. Refresh the preview before creating the file." }); card.createEl("button", { text: "Refresh preview", cls: "mod-cta" }).addEventListener("click", () => { void this.prepare(true); }); } this.updateCreateButton();
|
||||
}
|
||||
private applyDocumentIdentity(folder: TFolder): void {
|
||||
const notes = folder.children.filter((item): item is TFile => item instanceof TFile && item.extension.toLowerCase() === "md");
|
||||
const records = notes.map((file) => ({ file, frontmatter: this.app.metadataCache.getFileCache(file)?.frontmatter as Record<string, unknown> | undefined }));
|
||||
const records = notes.map((file) => ({ file, frontmatter: recordValue(this.app.metadataCache.getFileCache(file)?.frontmatter) }));
|
||||
const rootRecord = records.find(({ file }) => cleanIdentity(file.basename) === cleanIdentity(folder.name));
|
||||
const projectRecord = records.find(({ frontmatter }) => isBookMetadata(frontmatter));
|
||||
const title = resolveBookTitle(metadataValue(projectRecord?.frontmatter, ["booktitle", "book title", "project title"]), metadataValue(rootRecord?.frontmatter, ["title", "booktitle", "book title"]), folder.name);
|
||||
|
|
@ -102,3 +103,4 @@ export class SimpleCompileModal extends Modal {
|
|||
function cleanIdentity(value: string): string { return value.replace(/\.[^.]+$/, "").replace(/[^a-z0-9]+/gi, " ").trim().toLowerCase(); }
|
||||
function metadataValue(record: Record<string, unknown> | undefined, keys: string[]): unknown { if (!record) return; const normalized = new Map(Object.entries(record).map(([key, value]) => [key.toLowerCase().replace(/[_-]+/g, " "), value])); for (const key of keys) { const value = normalized.get(key); if (typeof value === "string" && value.trim()) return value; } return; }
|
||||
function isBookMetadata(record: Record<string, unknown> | undefined): boolean { if (!record) return false; const type = metadataValue(record, ["type", "note type", "category"]); return typeof type === "string" && /^(?:book|project|manuscript)$/i.test(type.trim()) || metadataValue(record, ["booktitle", "book title", "project title"]) !== undefined; }
|
||||
function recordValue(value: unknown): Record<string, unknown> | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : undefined; }
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
* or scanner-to-export shortcuts are forbidden because they bypass author roles
|
||||
* and safe exclusions.
|
||||
*/
|
||||
import { TFile, TFolder, type Vault } from "obsidian";
|
||||
import { normalizePath, TFile, TFolder, type Vault } from "obsidian";
|
||||
import { ManuscriptCompiler } from "./compiler";
|
||||
import { applyContentPlan, classifyContentPlan, createContentPlan, isPlanItemIncluded, type ContentPlanItem } from "./content-plan";
|
||||
import type { Book, CompileResult, CompileWarning, ManuscriptStatistics } from "./model";
|
||||
|
|
@ -83,7 +83,7 @@ export class CompilePreparationService {
|
|||
async prepareAuthoritative(request: CompilePreparationRequest, signal?: AbortSignal): Promise<PreparedCompileSession> {
|
||||
const preparationStarted = performance.now();
|
||||
const scanStarted = performance.now();
|
||||
const folder = this.vault.getAbstractFileByPath(request.manuscriptRoot);
|
||||
const folder = this.vault.getAbstractFileByPath(normalizePath(request.manuscriptRoot));
|
||||
if (!(folder instanceof TFolder)) throw new Error("The manuscript folder does not exist.");
|
||||
const suppliedPlan = request.contentPlan;
|
||||
const plan = suppliedPlan === undefined
|
||||
|
|
@ -204,6 +204,7 @@ function simpleRequestFromProfile(request: CompilePreparationRequest, plan: Cont
|
|||
font: profile.docxFont ?? "Times New Roman",
|
||||
fontSize: profile.docxFontSize ?? 12,
|
||||
lineSpacing: profile.docxLineSpacing ?? 2,
|
||||
indentParagraphs: profile.docxIndentParagraphs ?? true,
|
||||
firstLineIndentCm: profile.docxFirstLineIndentCm ?? inchesToCentimetres(profile.docxFirstLineIndent ?? 0.5),
|
||||
pageSize: profile.docxPageSize ?? "a4",
|
||||
chapterPageBreak: profile.docxChapterPageBreak ?? true,
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function createContentPlan(root: TFolder, preset: StructurePreset): Conte
|
|||
children.forEach((child, order) => {
|
||||
const kind = child instanceof TFolder ? "folder" : "note";
|
||||
const suggestion = inheritedMatter ? { role: inheritedMatter } : inferredRole(child, semanticDepth, preset, normalizedRootName); const excluded = suggestion.role === "ignore";
|
||||
items.push({ path: child.path, parentPath: folder.path, name: kind === "note" ? (child as TFile).basename : child.name, kind, role: suggestion.role, detectedRole: suggestion.role, included: !excluded, order, exclusionReason: suggestion.reason });
|
||||
items.push({ path: child.path, parentPath: folder.path, name: child instanceof TFile ? child.basename : child.name, kind, role: suggestion.role, detectedRole: suggestion.role, included: !excluded, order, exclusionReason: suggestion.reason });
|
||||
if (child instanceof TFolder) {
|
||||
const childMatter = matterContext(child, suggestion.role, inheritedMatter);
|
||||
visit(child, semanticDepth + (suggestion.role === "transparent" || suggestion.role === "front-matter" || suggestion.role === "back-matter" || suggestion.role === "ignore" ? 0 : 1), childMatter);
|
||||
|
|
@ -120,7 +120,7 @@ export function applyMatterRoleInheritance(plan: ContentPlanItem[], folderPath:
|
|||
}
|
||||
export function isPlanItemIncluded(item: ContentPlanItem, plan: ContentPlanItem[], rootPath: string): boolean { const byPath = new Map(plan.map((candidate) => [candidate.path, candidate])); let current: ContentPlanItem | undefined = item; while (current) { if (!current.included || current.role === "ignore") return false; if (current.parentPath === rootPath) break; current = byPath.get(current.parentPath); } return true; }
|
||||
|
||||
function frontmatter(markdown: string): Record<string, unknown> { const match = markdown.replace(/^\uFEFF/, "").match(/^---[\t ]*\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)[\t ]*(?:\r?\n|$)/); if (!match) return {}; try { const value = parseYaml(match[1]); if (!value || typeof value !== "object" || Array.isArray(value)) return {}; return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [normalizedProjectName(key), item])); } catch { return {}; } }
|
||||
function frontmatter(markdown: string): Record<string, unknown> { const match = markdown.replace(/^\uFEFF/, "").match(/^---[\t ]*\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)[\t ]*(?:\r?\n|$)/); if (!match) return {}; try { const value: unknown = parseYaml(match[1]); if (!value || typeof value !== "object" || Array.isArray(value)) return {}; return Object.fromEntries(Object.entries(value).map(([key, item]) => [normalizedProjectName(key), item])); } catch { return {}; } }
|
||||
|
||||
/**
|
||||
* Reconstructs scanner output using nearest included structural ancestors. This
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface DocxOptions {
|
|||
font?: string;
|
||||
fontSize?: number;
|
||||
lineSpacing?: number;
|
||||
indentParagraphs?: boolean;
|
||||
firstLineIndentCm?: number;
|
||||
pageSize?: "letter" | "a4";
|
||||
chapterPageBreak?: boolean;
|
||||
|
|
@ -37,6 +38,7 @@ export interface ResolvedDocxOptions extends DocxOptions {
|
|||
font: string;
|
||||
fontSize: number;
|
||||
lineSpacing: number;
|
||||
indentParagraphs: boolean;
|
||||
firstLineIndentCm: number;
|
||||
pageSize: "letter" | "a4";
|
||||
chapterPageBreak: boolean;
|
||||
|
|
@ -65,6 +67,7 @@ export function resolveDocxOptions(options: DocxOptions): ResolvedDocxOptions {
|
|||
font: cleanFont(options.font),
|
||||
fontSize: clamp(options.fontSize, 8, 24, 12),
|
||||
lineSpacing: clamp(options.lineSpacing, 0.8, 3, 2),
|
||||
indentParagraphs: options.indentParagraphs !== false,
|
||||
firstLineIndentCm: clampCentimetres(options.firstLineIndentCm, 0, 3.81, 1.27),
|
||||
pageSize: options.pageSize === "letter" ? "letter" : "a4",
|
||||
chapterPageBreak: options.chapterPageBreak !== false,
|
||||
|
|
@ -271,7 +274,7 @@ function stylesXml(options: ResolvedDocxOptions): string {
|
|||
const font = escapeXml(options.font);
|
||||
const size = Math.round(options.fontSize * 2);
|
||||
const line = Math.round(options.lineSpacing * 240);
|
||||
const indent = centimetresToTwips(options.firstLineIndentCm);
|
||||
const indent = options.indentParagraphs ? centimetresToTwips(options.firstLineIndentCm) : 0;
|
||||
const style = (id: string, name: string, basedOn: string, paragraphProperties: string, runProperties = ""): string => `<w:style w:type="paragraph" w:styleId="${id}"><w:name w:val="${name}"/>${basedOn === id ? "" : `<w:basedOn w:val="${basedOn}"/>`}<w:next w:val="${id === "BodyText" ? "BodyText" : "FirstParagraph"}"/><w:qFormat/><w:pPr>${paragraphProperties}</w:pPr>${runProperties ? `<w:rPr>${runProperties}</w:rPr>` : ""}</w:style>`;
|
||||
const centered = `<w:jc w:val="center"/><w:ind w:firstLine="0"/>`;
|
||||
return `${XML}<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="${font}" w:hAnsi="${font}" w:eastAsia="${font}"/><w:sz w:val="${size}"/><w:lang w:val="en-US"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="0" w:line="${line}" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults>${style("Normal", "Normal", "Normal", `<w:spacing w:after="0" w:line="${line}" w:lineRule="auto"/>`)}${style("Title", "Title", "Normal", `${centered}<w:spacing w:before="3600" w:after="480"/>`, `<w:b/><w:sz w:val="40"/>`)}${style("Author", "Author", "Normal", `${centered}<w:spacing w:before="480"/>`, `<w:sz w:val="28"/>`)}${style("PartNumber", "Part Number", "Normal", `${centered}<w:spacing w:before="1440" w:after="240"/>`, `<w:b/><w:sz w:val="32"/>`)}${style("PartTitle", "Part Title", "Normal", `${centered}<w:spacing w:after="720"/>`, `<w:b/><w:sz w:val="32"/>`)}${style("ChapterNumber", "Chapter Number", "Normal", `${centered}<w:spacing w:before="720" w:after="180"/>`, `<w:b/><w:sz w:val="28"/>`)}${style("ChapterTitle", "Chapter Title", "Normal", `${centered}<w:spacing w:after="720"/>`, `<w:b/><w:sz w:val="28"/>`)}${style("BodyText", "Body Text", "Normal", `<w:spacing w:after="0" w:line="${line}" w:lineRule="auto"/><w:ind w:firstLine="${indent}"/>`)}${style("FirstParagraph", "First Paragraph", "BodyText", `<w:spacing w:after="0" w:line="${line}" w:lineRule="auto"/><w:ind w:firstLine="0"/>`)}${style("SceneBreak", "Scene Break", "Normal", `${centered}<w:spacing w:before="240" w:after="240"/>`)}${style("FrontMatterHeading", "Front Matter Heading", "Normal", `${centered}<w:spacing w:before="720" w:after="480"/>`, `<w:b/><w:sz w:val="28"/>`)}${style("BackMatterHeading", "Back Matter Heading", "Normal", `${centered}<w:spacing w:before="720" w:after="480"/>`, `<w:b/><w:sz w:val="28"/>`)}</w:styles>`;
|
||||
|
|
|
|||
|
|
@ -30,11 +30,11 @@ export class ExportCoordinator {
|
|||
try {
|
||||
await this.verifySession(session); let visibleIssues = new WarningEngine().filter(session.warnings, this.settings().minimumWarningLevel); if (!canProceedWithExport(session.warnings)) throw new Error("Export is blocked by an unsafe or invalid configuration. Review the final preview.");
|
||||
await this.persistDefaults(session, format); progress.update(`Creating ${details.label}`);
|
||||
const formatting = session.request.formatting ?? { font: "Times New Roman", fontSize: 12, lineSpacing: 2, firstLineIndentCm: 1.27, pageSize: "a4" as const, chapterPageBreak: true, titlePage: false };
|
||||
const exportOptions = { title, author: String(session.variables.Author ?? ""), language: "en", titlePage: formatting.titlePage, tableOfContents: session.profile.generateTableOfContents, font: formatting.font, fontSize: formatting.fontSize, lineSpacing: formatting.lineSpacing, firstLineIndentCm: formatting.firstLineIndentCm, pageSize: formatting.pageSize, pageMarginCm: formatting.pageMarginCm ?? 2.54, chapterPageBreak: formatting.chapterPageBreak, sceneSeparator: session.profile.sceneSeparator };
|
||||
const document = createSemanticDocument(session.book, session.profile, exportOptions, session.statistics.totalWordCount); const exportStarted = performance.now();
|
||||
const generated = await EXPORTERS[format].generate({ session, document, options: exportOptions, filename }); visibleIssues = new WarningEngine().filter([...session.warnings, ...generated.warnings], this.settings().minimumWarningLevel); if (operation.signal.aborted) throw new CompilationCancelledError();
|
||||
progress.update(`Validating ${details.label}`); const validation = EXPORT_VALIDATORS[format].validate(generated.bytes); if (!validation.valid) throw new Error(`The generated ${details.label} did not pass validation and was not downloaded. ${validation.errors.join(" ")}`);
|
||||
const formatting = session.request.formatting ?? { font: "Times New Roman", fontSize: 12, lineSpacing: 2, indentParagraphs: true, firstLineIndentCm: 1.27, pageSize: "a4" as const, chapterPageBreak: true, titlePage: false };
|
||||
const exportOptions = { title, author: String(session.variables.Author ?? ""), language: "en", titlePage: formatting.titlePage, tableOfContents: session.profile.generateTableOfContents, font: formatting.font, fontSize: formatting.fontSize, lineSpacing: formatting.lineSpacing, indentParagraphs: formatting.indentParagraphs, firstLineIndentCm: formatting.firstLineIndentCm, pageSize: formatting.pageSize, chapterPageBreak: formatting.chapterPageBreak, sceneSeparator: session.profile.sceneSeparator };
|
||||
const document = createSemanticDocument(session.book, session.profile, exportOptions, session.statistics.totalWordCount); const exportStarted = performance.now(); const context = { session, document, options: exportOptions, filename };
|
||||
const generated = await EXPORTERS[format].generate(context); visibleIssues = new WarningEngine().filter([...session.warnings, ...generated.warnings], this.settings().minimumWarningLevel); if (operation.signal.aborted) throw new CompilationCancelledError();
|
||||
progress.update(`Validating ${details.label}`); const validation = EXPORT_VALIDATORS[format].validate(generated.bytes, context); if (!validation.valid) throw new Error(`The generated ${details.label} did not pass validation and was not downloaded. ${validation.errors.join(" ")}`);
|
||||
operation.finalise(); progress.lock("Starting download…"); const downloaded = await this.downloads.download({ filename: generated.filename, bytes: generated.bytes, mimeType: generated.mimeType }); if (!downloaded.started) throw new Error(downloaded.error || "The host blocked the download. Try again.");
|
||||
const exportMs = performance.now() - exportStarted; const timings = session.result.timings ?? { totalMs: 0, scanMs: 0, parseMs: 0, filterMs: 0, generationMs: 0, exportMs: 0 };
|
||||
const report: CompileResult = { ...session.result, issues: visibleIssues, warnings: visibleIssues.map((item) => item.message), timings: { ...timings, totalMs: timings.totalMs + Date.now() - started, exportMs } };
|
||||
|
|
@ -53,7 +53,7 @@ export class ExportCoordinator {
|
|||
}
|
||||
|
||||
private async verifySession(session: PreparedCompileSession): Promise<void> { if (compileInputSignature(session.request, session.contentPlan) !== session.inputSignature) throw new Error("The compile choices changed after the preview was prepared. Refresh the preview before creating the file."); if (await calculateSourceFingerprint(this.app.vault, session.sourcePaths) !== session.sourceFingerprint) throw new Error("The manuscript changed after the preview was prepared. Refresh the preview before creating the file."); }
|
||||
private async persistDefaults(session: PreparedCompileSession, format: ExportFormat): Promise<void> { const settings = this.settings(); settings.defaultManuscriptFolder = session.request.manuscriptRoot; settings.defaultStructurePreset = session.request.structurePreset; settings.defaultDownloadFormat = format; if (session.request.docxPreset !== "custom") settings.defaultDocxStyle = session.request.docxPreset; if (session.request.formatting) { settings.defaultDocxPageSize = session.request.formatting.pageSize; settings.defaultDocxFirstLineIndentCm = session.request.formatting.firstLineIndentCm; } await this.saveSettings(); }
|
||||
private async persistDefaults(session: PreparedCompileSession, format: ExportFormat): Promise<void> { const settings = this.settings(); settings.defaultManuscriptFolder = session.request.manuscriptRoot; settings.defaultStructurePreset = session.request.structurePreset; settings.defaultDownloadFormat = format; if (session.request.docxPreset !== "custom") settings.defaultDocxStyle = session.request.docxPreset; if (session.request.formatting) { settings.defaultDocxPageSize = session.request.formatting.pageSize; settings.defaultIndentParagraphs = session.request.formatting.indentParagraphs; settings.defaultDocxFirstLineIndentCm = session.request.formatting.firstLineIndentCm; } await this.saveSettings(); }
|
||||
}
|
||||
|
||||
export function friendlyExportError(error: unknown): string { const message = error instanceof Error ? error.message : String(error); if (/validation/i.test(message)) return message; if (/blocked|download/i.test(message)) return message; return `The file could not be created. ${message}`; }
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import { EXPORT_FORMAT_DETAILS, type ExportFormat } from "./export-types";
|
|||
|
||||
export function exportFilename(value: string, format: ExportFormat, fallback = "Manuscript"): string {
|
||||
const resolved = value.replace(/\{BookTitle\}/gi, fallback);
|
||||
const leaf = resolved.replace(/^.*[\\/]/, "").replace(/(?:\.(?:docx|odt|pdf|epub|html?|xml))+$/i, "");
|
||||
let safe = leaf.replace(/[\u0000-\u001f\u007f\\/:*?"<>|]/g, "-").trim().replace(/[. ]+$/g, "") || fallback;
|
||||
const leaf = resolved.replace(/^.*[\\/]/, "").replace(/(?:\.(?:docx|odt|epub|html?|md|markdown|xml))+$/i, "");
|
||||
let safe = replaceUnsafeFilenameCharacters(leaf).trim().replace(/[. ]+$/g, "") || fallback;
|
||||
if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(safe)) safe = `_${safe}`;
|
||||
return `${safe}.${EXPORT_FORMAT_DETAILS[format].extension}`;
|
||||
}
|
||||
|
||||
function replaceUnsafeFilenameCharacters(value: string): string { return [...value].map((character) => { const code = character.charCodeAt(0); return code <= 0x1f || code === 0x7f || '\\/:*?"<>|'.includes(character) ? "-" : character; }).join(""); }
|
||||
|
|
|
|||
|
|
@ -3,25 +3,25 @@ import type { PreparedCompileSession } from "./compile-preparation";
|
|||
import type { CompileWarning } from "./model";
|
||||
import type { SemanticDocument } from "./semantic-document";
|
||||
|
||||
export const EXPORT_FORMATS = ["docx", "odt", "pdf", "epub", "html", "xml"] as const;
|
||||
export const EXPORT_FORMATS = ["docx", "odt", "epub", "html", "markdown", "xml"] as const;
|
||||
export type ExportFormat = typeof EXPORT_FORMATS[number];
|
||||
|
||||
export const EXPORT_FORMAT_DETAILS: Record<ExportFormat, { label: string; extension: string; mimeType: string; description: string }> = {
|
||||
docx: { label: "DOCX", extension: "docx", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", description: "Word document suitable for Vellum and editing" },
|
||||
odt: { label: "ODT", extension: "odt", mimeType: "application/vnd.oasis.opendocument.text", description: "OpenDocument text for LibreOffice and compatible editors" },
|
||||
pdf: { label: "PDF", extension: "pdf", mimeType: "application/pdf", description: "Fixed-layout reading or review document" },
|
||||
epub: { label: "EPUB", extension: "epub", mimeType: "application/epub+zip", description: "Reflowable ebook package" },
|
||||
html: { label: "HTML", extension: "html", mimeType: "text/html;charset=utf-8", description: "Standalone web document" },
|
||||
markdown: { label: "Markdown", extension: "md", mimeType: "text/markdown;charset=utf-8", description: "Portable plain-text manuscript" },
|
||||
xml: { label: "XML", extension: "xml", mimeType: "application/xml", description: "Structured manuscript data for interchange and automation" }
|
||||
};
|
||||
|
||||
export interface ExportFormattingOptions {
|
||||
title: string; author: string; language: string; titlePage: boolean; tableOfContents: boolean;
|
||||
font: string; fontSize: number; lineSpacing: number; firstLineIndentCm: number; pageSize: "a4" | "letter";
|
||||
pageMarginCm: number; chapterPageBreak: boolean; sceneSeparator: string;
|
||||
font: string; fontSize: number; lineSpacing: number; indentParagraphs: boolean; firstLineIndentCm: number; pageSize: "a4" | "letter";
|
||||
chapterPageBreak: boolean; sceneSeparator: string;
|
||||
}
|
||||
export interface GeneratedExport { format: ExportFormat; filename: string; mimeType: string; bytes: Uint8Array; warnings: CompileWarning[]; }
|
||||
export interface ExportValidationResult { valid: boolean; errors: string[]; }
|
||||
export interface ManuscriptExportContext { session: PreparedCompileSession; document: SemanticDocument; options: ExportFormattingOptions; filename: string; }
|
||||
export interface ManuscriptExporter { readonly format: ExportFormat; generate(context: ManuscriptExportContext): Promise<GeneratedExport>; }
|
||||
export interface ExportValidator { validate(bytes: Uint8Array): ExportValidationResult; }
|
||||
export interface ExportValidator { validate(bytes: Uint8Array, context?: ManuscriptExportContext): ExportValidationResult; }
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/** Structural validators run before browser download dispatch. */
|
||||
import { strFromU8, unzipSync } from "fflate";
|
||||
import { validateDocxBytes } from "./docx-validator";
|
||||
import type { ExportFormat, ExportValidationResult, ExportValidator } from "./export-types";
|
||||
import { inspectNativePdfTextLines, measureNativePdfText, recoverNativePdfText } from "./native-pdf";
|
||||
import type { ExportFormat, ExportValidationResult, ExportValidator, ManuscriptExportContext } from "./export-types";
|
||||
import { renderSemanticMarkdown } from "./markdown-exporter";
|
||||
|
||||
const valid = (): ExportValidationResult => ({ valid: true, errors: [] });
|
||||
const invalid = (...errors: string[]): ExportValidationResult => ({ valid: false, errors });
|
||||
|
|
@ -12,46 +12,46 @@ class DocxValidator implements ExportValidator { validate(bytes: Uint8Array): Ex
|
|||
class OdtValidator implements ExportValidator {
|
||||
validate(bytes: Uint8Array): ExportValidationResult { try { const files = unzipSync(bytes); const required = ["mimetype", "META-INF/manifest.xml", "content.xml", "styles.xml", "meta.xml", "settings.xml"]; const missing = required.filter((name) => !files[name]); if (missing.length) return invalid(`Missing ODT entries: ${missing.join(", ")}`); if (strFromU8(files.mimetype) !== "application/vnd.oasis.opendocument.text") return invalid("ODT mimetype is invalid."); if (firstZipEntry(bytes) !== "mimetype" || !firstZipEntryStored(bytes)) return invalid("ODT mimetype must be the first uncompressed package entry."); const manifest = strFromU8(files["META-INF/manifest.xml"]); for (const match of manifest.matchAll(/manifest:full-path="([^"]+)"/g)) if (match[1] !== "/" && !files[match[1]]) return invalid(`ODT manifest target ${match[1]} is missing.`); const content = strFromU8(files["content.xml"]); const styles = strFromU8(files["styles.xml"]); if (![manifest, content, styles, strFromU8(files["meta.xml"]), strFromU8(files["settings.xml"])].every(wellFormedXml)) return invalid("ODT contains malformed XML."); if (!/<office:text>[\s\S]*<text:p/.test(content)) return invalid("ODT has no text body."); for (const style of requiredStyles) if (!styles.includes(`style:name="${style}"`)) return invalid(`ODT style ${style} is missing.`); return valid(); } catch { return invalid("ODT is not a readable ZIP package."); } }
|
||||
}
|
||||
class PdfValidator implements ExportValidator {
|
||||
validate(bytes: Uint8Array): ExportValidationResult {
|
||||
const text = new TextDecoder("latin1").decode(bytes); const errors: string[] = [];
|
||||
if (!text.startsWith("%PDF-")) errors.push("PDF header is missing.");
|
||||
if (bytes.length < 500) errors.push("PDF is unexpectedly small.");
|
||||
if (!/\/Type \/Catalog\b/.test(text)) errors.push("PDF Catalog is missing.");
|
||||
if (!/\/Type \/Pages\b/.test(text)) errors.push("PDF Pages tree is missing.");
|
||||
if ((text.match(/\/Type \/Page\b/g) ?? []).length < 1) errors.push("PDF contains no pages.");
|
||||
if (!/\/Resources\s*<<[\s\S]*?\/Font\s*<</.test(text) || !/\/Subtype \/Type1\b/.test(text)) errors.push("PDF font resource is missing or unsupported.");
|
||||
if (!/\/Encoding \/WinAnsiEncoding\b/.test(text)) errors.push("PDF font encoding is missing.");
|
||||
if (!/\/ToUnicode\s+\d+\s+0\s+R/.test(text) || !/begincmap[\s\S]*beginbfchar[\s\S]*endcmap/.test(text)) errors.push("PDF ToUnicode mapping is missing.");
|
||||
if (!/\/Contents\s+\d+\s+0\s+R/.test(text) || !/stream\n[\s\S]*?\bBT\b[\s\S]*?\bTj\b[\s\S]*?endstream/.test(text)) errors.push("PDF content stream is missing.");
|
||||
if (/\/CIDToGIDMap \/Identity|\/Encoding \/Identity-H/.test(text)) errors.push("PDF uses an unsafe identity glyph mapping without an embedded font.");
|
||||
if (/\([^\r\n)]*[\x80-\xFF][^\r\n)]*\)\s*Tj/.test(text)) errors.push("PDF contains non-ASCII bytes in a literal text string.");
|
||||
for (const match of text.matchAll(/<([0-9A-F]*)>\s*Tj/g)) if (match[1].length % 2 !== 0) errors.push("PDF contains an odd-length hexadecimal text string.");
|
||||
if (!recoverNativePdfText(bytes).trim()) errors.push("PDF text cannot be recovered through ToUnicode.");
|
||||
const media = /\/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]/.exec(text); const layout = /\/MCLayout << \/LeftMargin ([\d.]+) \/RightMargin ([\d.]+) \/TopMargin ([\d.]+) \/BottomMargin ([\d.]+) \/UsableWidth ([\d.]+) >>/.exec(text);
|
||||
if (!media || !layout) errors.push("PDF page or layout dimensions are missing.");
|
||||
else {
|
||||
const pageWidth = Number(media[1]); const pageHeight = Number(media[2]); const left = Number(layout[1]); const right = Number(layout[2]); const top = Number(layout[3]); const bottom = Number(layout[4]); const usable = Number(layout[5]);
|
||||
const knownPage = Math.abs(pageWidth - 595.28) < 0.1 && Math.abs(pageHeight - 841.89) < 0.1 || Math.abs(pageWidth - 612) < 0.1 && Math.abs(pageHeight - 792) < 0.1; if (!knownPage) errors.push("PDF page dimensions are not A4 or Letter points.");
|
||||
if (left < 18 || right < 18 || top < 18 || bottom < 18 || left > pageWidth / 3 || right > pageWidth / 3) errors.push("PDF margins are outside plausible bounds.");
|
||||
if (Math.abs(usable - (pageWidth - left - right)) > 0.1 || usable < pageWidth * 0.6) errors.push("PDF usable text width is invalid or implausibly narrow.");
|
||||
const font = /\/BaseFont \/(Times-Roman|Helvetica)/.exec(text)?.[1] as "Times-Roman" | "Helvetica" | undefined; const lines = inspectNativePdfTextLines(bytes); const textOperatorCount = (text.match(/\bTj\b/g) ?? []).length; if (!font || !lines.length || lines.length !== textOperatorCount) errors.push("PDF text matrices cannot be inspected.");
|
||||
else for (const line of lines) { const measured = measureNativePdfText(line.text, font, line.size, line.bold); if (line.size < 6 || line.size > 72 || line.leading <= 0 || line.leading < line.size * 0.8 || line.leading > line.size * 4) errors.push("PDF font size or leading is implausible."); if (line.x < left - 0.2 || line.x + measured > pageWidth - right + 0.5) errors.push("PDF text falls outside the horizontal margins."); if (line.y < bottom - 0.2 || line.y > pageHeight - top + 0.2) errors.push("PDF text falls outside the vertical margins."); }
|
||||
}
|
||||
for (const match of text.matchAll(/<< \/Length (\d+) >>\nstream\n/g)) { const start = match.index + match[0].length; const end = start + Number(match[1]); if (text.slice(end, end + 10) !== "\nendstream") errors.push("PDF stream length does not match its byte payload."); }
|
||||
const xref = /xref\n0 (\d+)\n0000000000 65535 f \n([\s\S]*?)\ntrailer/.exec(text); if (xref) { const entries = xref[2].split("\n"); if (entries.length !== Number(xref[1]) - 1) errors.push("PDF cross-reference entry count is invalid."); entries.forEach((entry, index) => { const offset = Number(entry.slice(0, 10)); if (!Number.isInteger(offset) || !text.startsWith(`${index + 1} 0 obj\n`, offset)) errors.push("PDF cross-reference offset is invalid."); }); } else errors.push("PDF cross-reference table is missing.");
|
||||
const start = /startxref\s+(\d+)\s+%%EOF\s*$/.exec(text); if (!start || text.slice(Number(start[1]), Number(start[1]) + 4) !== "xref") errors.push("PDF startxref does not point to the cross-reference table.");
|
||||
return errors.length ? invalid(...new Set(errors)) : valid();
|
||||
}
|
||||
}
|
||||
class EpubValidator implements ExportValidator {
|
||||
validate(bytes: Uint8Array): ExportValidationResult { try { const files = unzipSync(bytes); if (firstZipEntry(bytes) !== "mimetype" || !firstZipEntryStored(bytes) || strFromU8(files.mimetype ?? new Uint8Array()) !== "application/epub+zip") return invalid("EPUB mimetype is invalid, compressed, or misplaced."); for (const name of ["META-INF/container.xml", "OEBPS/content.opf", "OEBPS/nav.xhtml", "OEBPS/style.css"]) if (!files[name]) return invalid(`EPUB entry ${name} is missing.`); const container = strFromU8(files["META-INF/container.xml"]); if (!wellFormedXml(container) || !container.includes(`full-path="OEBPS/content.opf"`)) return invalid("EPUB container rootfile is invalid."); const opf = strFromU8(files["OEBPS/content.opf"]); if (!wellFormedXml(opf)) return invalid("EPUB package document is malformed."); const ids = new Set([...opf.matchAll(/<item\s+id="([^"]+)"[^>]*href="([^"]+)"/g)].map((match) => match[1])); for (const match of opf.matchAll(/<item\s+id="[^"]+"[^>]*href="([^"]+)"/g)) if (!files[`OEBPS/${match[1]}`]) return invalid(`EPUB manifest target ${match[1]} is missing.`); for (const match of opf.matchAll(/<itemref\s+idref="([^"]+)"/g)) if (!ids.has(match[1])) return invalid(`EPUB spine reference ${match[1]} is invalid.`); const xhtmlEntries = Object.entries(files).filter(([name]) => name.endsWith(".xhtml")); const xhtml = xhtmlEntries.map(([, value]) => strFromU8(value)).join("\n"); if (!xhtmlEntries.every(([, value]) => wellFormedXml(strFromU8(value)))) return invalid("EPUB XHTML is malformed."); for (const match of xhtml.matchAll(/(?:src|href)=["']([^"'#]+)(?:#[^"']*)?["']/gi)) if (!/^[a-z]+:/i.test(match[1]) && !files[`OEBPS/${match[1]}`]) return invalid(`EPUB internal reference ${match[1]} is missing.`); if (/<script\b|(?:src|href)=["']https?:|url\(\s*https?:/i.test(xhtml)) return invalid("EPUB contains scripts or remote resources."); return valid(); } catch { return invalid("EPUB is not a readable ZIP package."); } }
|
||||
}
|
||||
class HtmlValidator implements ExportValidator { validate(bytes: Uint8Array): ExportValidationResult { const text = decode(bytes); if (!/^<!doctype html>/i.test(text) || !/<html\b[\s\S]*<head\b[\s\S]*<body\b/i.test(text)) return invalid("HTML document structure is incomplete."); if (!/<meta charset="utf-8">/i.test(text) || !/<title>[^<]+<\/title>/i.test(text) || !/<main>[\s\S]*<section\b/.test(text)) return invalid("HTML metadata or manuscript content is missing."); if (/<script\b|(?:src|href)=["']https?:|url\(\s*https?:/i.test(text)) return invalid("HTML contains scripts or remote resources."); return valid(); } }
|
||||
export class MarkdownValidator implements ExportValidator {
|
||||
validate(bytes: Uint8Array, context?: ManuscriptExportContext): ExportValidationResult {
|
||||
const errors: string[] = []; let text = "";
|
||||
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return invalid("Markdown is not valid UTF-8."); }
|
||||
if (!text.trim()) errors.push("Markdown manuscript is empty.");
|
||||
if (!text.endsWith("\n") || text.endsWith("\n\n")) errors.push("Markdown must end with exactly one newline.");
|
||||
if (/\r/.test(text)) errors.push("Markdown contains non-canonical line endings.");
|
||||
if (/\n{3,}/.test(text)) errors.push("Markdown contains duplicate blank lines.");
|
||||
if (/(?:^|\n\n)---\n(?:[A-Za-z_][\w -]*:\s*[^\n]*\n)+---(?:\n|$)/.test(text)) errors.push("Markdown contains YAML frontmatter.");
|
||||
if (/^#{1,6}\s+(?:Synopsis|Revision Notes)\s*$/im.test(text)) errors.push("Markdown contains an excluded authoring section.");
|
||||
if (/^#{1,6}\s+.*dashboard.*$/im.test(text)) errors.push("Markdown contains a dashboard heading.");
|
||||
if (/^#{1,6}\s+Part\s+(?:0|Zero)\b/im.test(text)) errors.push("Markdown contains Part 0.");
|
||||
if (/^#{1,6}\s+Chapter\s+(?:0|Zero)\b/im.test(text)) errors.push("Markdown contains Chapter 0.");
|
||||
const headings = text.split("\n").filter((line) => /^#{1,6}\s+\S/.test(line)).map((line) => line.replace(/^#{1,6}\s+/, "").trim().toLocaleLowerCase());
|
||||
if (headings.some((heading, index) => index > 0 && heading === headings[index - 1])) errors.push("Markdown contains duplicate adjacent headings.");
|
||||
if (context) this.validateContext(text, bytes, context, errors);
|
||||
return errors.length ? invalid(...new Set(errors)) : valid();
|
||||
}
|
||||
|
||||
private validateContext(text: string, bytes: Uint8Array, context: ManuscriptExportContext, errors: string[]): void {
|
||||
const { document, options } = context;
|
||||
const expected = new TextEncoder().encode(renderSemanticMarkdown(document));
|
||||
if (expected.length !== bytes.length || expected.some((value, index) => value !== bytes[index])) errors.push("Markdown output is not the deterministic semantic-document rendering.");
|
||||
if (options.titlePage && (!text.includes(`# ${document.title}`) || document.author && !text.includes(document.author))) errors.push("Markdown title page or author is missing.");
|
||||
for (const section of document.sections) {
|
||||
if ((section.kind === "front-matter" || section.kind === "back-matter") && !text.includes(`# ${section.title}`)) errors.push(`Markdown ${section.kind} heading is missing.`);
|
||||
if ((section.kind === "part" || section.kind === "chapter") && section.number !== 0 && !section.blocks.some((block) => block.kind === "heading" && text.includes(block.inlines.map((item) => item.text).join("")))) errors.push(`Markdown ${section.kind} heading is missing.`);
|
||||
}
|
||||
const separators = document.sections.flatMap((section) => section.blocks).filter((block) => block.kind === "scene-break").map((block) => block.kind === "scene-break" ? block.text.trim() || "* * *" : ""); const outputLines = text.split("\n");
|
||||
for (const separator of new Set(separators)) if (outputLines.filter((line) => line === separator).length !== separators.filter((value) => value === separator).length) errors.push("Markdown scene separator count is incorrect.");
|
||||
}
|
||||
}
|
||||
class XmlValidator implements ExportValidator { validate(bytes: Uint8Array): ExportValidationResult { const text = decode(bytes); if (!text.startsWith(`<?xml version="1.0" encoding="UTF-8"?>`)) return invalid("XML declaration is missing."); if (!wellFormedXml(text)) return invalid("XML element nesting is malformed."); if (!/<manuscript xmlns="https:\/\/manuscript-compiler\.dev\/schema" schemaVersion="1\.0">/.test(text)) return invalid("XML namespace or schema version is invalid."); if (!/<metadata>[\s\S]*<title>[\s\S]*<frontMatter>[\s\S]*<body>[\s\S]*<backMatter>/.test(text)) return invalid("XML manuscript hierarchy is incomplete."); if (/(?:file|vault|profile)Path=|<settings>|<profileId>/i.test(text)) return invalid("XML contains compiler-internal data."); return valid(); } }
|
||||
|
||||
const requiredStyles = ["Title", "Author", "FrontMatterHeading", "BackMatterHeading", "PartNumber", "PartTitle", "ChapterNumber", "ChapterTitle", "FirstParagraph", "BodyText", "SceneBreak"];
|
||||
export const EXPORT_VALIDATORS: Record<ExportFormat, ExportValidator> = { docx: new DocxValidator(), odt: new OdtValidator(), pdf: new PdfValidator(), epub: new EpubValidator(), html: new HtmlValidator(), xml: new XmlValidator() };
|
||||
export const EXPORT_VALIDATORS: Record<ExportFormat, ExportValidator> = { docx: new DocxValidator(), odt: new OdtValidator(), epub: new EpubValidator(), html: new HtmlValidator(), markdown: new MarkdownValidator(), xml: new XmlValidator() };
|
||||
|
||||
function firstZipEntry(bytes: Uint8Array): string { if (bytes.length < 30 || bytes[0] !== 0x50 || bytes[1] !== 0x4b || bytes[2] !== 0x03 || bytes[3] !== 0x04) return ""; const length = bytes[26] | bytes[27] << 8; return new TextDecoder().decode(bytes.slice(30, 30 + length)); }
|
||||
function firstZipEntryStored(bytes: Uint8Array): boolean { return bytes.length >= 10 && bytes[8] === 0 && bytes[9] === 0; }
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ function historyEntry(item: HistoryCandidate, index: number): ExportHistoryEntry
|
|||
downloadStarted: typeof item.downloadStarted === "boolean" ? item.downloadStarted : undefined
|
||||
};
|
||||
}
|
||||
function isExportTarget(value: unknown): value is CompileLogEntry["exportFormats"] { return typeof value === "string" && ["markdown", "docx", "markdown-docx", "odt", "pdf", "epub", "html", "xml"].includes(value); }
|
||||
function isExportTarget(value: unknown): value is CompileLogEntry["exportFormats"] { return typeof value === "string" && ["markdown", "docx", "markdown-docx", "odt", "epub", "html", "xml"].includes(value); }
|
||||
function stringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; }
|
||||
function safeText(value: unknown, fallback: string, maximum: number): string { return typeof value === "string" ? value.slice(0, maximum) : fallback; }
|
||||
function safeNumber(value: unknown): number { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; }
|
||||
|
|
|
|||
12
src/main.ts
12
src/main.ts
|
|
@ -59,7 +59,7 @@ export default class ManuscriptCompilerPlugin extends Plugin {
|
|||
this.settings = repairSettings(loaded);
|
||||
if (raw && raw.defaultStructurePreset === undefined) this.settings.defaultStructurePreset = inferStructurePreset(this.getActiveProfile());
|
||||
await this.saveSettings();
|
||||
if (this.settings.configurationWarnings.length > previousWarnings) new Notice("Manuscript Compiler repaired invalid settings. Run Validate Manuscript for details.", 8000);
|
||||
if (this.settings.configurationWarnings.length > previousWarnings) new Notice("Manuscript Compiler repaired invalid settings. Run validate manuscript for details.", 8000);
|
||||
}
|
||||
|
||||
/** Persists the complete repaired settings object through Obsidian's plugin storage. */
|
||||
|
|
@ -92,11 +92,11 @@ export default class ManuscriptCompilerPlugin extends Plugin {
|
|||
}
|
||||
|
||||
private registerCommands(): void {
|
||||
this.addCommand({ id: COMMAND_IDS.compileManuscript, name: "Compile Manuscript", callback: () => this.openCompiler() });
|
||||
this.addCommand({ id: COMMAND_IDS.compileCurrentBook, name: "Compile Current Book", callback: () => { void this.commands.compileCurrentBook(); } });
|
||||
this.addCommand({ id: COMMAND_IDS.compileSelectedFolder, name: "Compile Selected Folder", callback: () => { new FolderSuggestModal(this.app, (folder) => { void this.commands.compileFolder(folder, undefined, [], "selected-folder"); }).open(); } });
|
||||
this.addCommand({ id: COMMAND_IDS.validateManuscript, name: "Validate Manuscript", callback: () => { void this.commands.validateManuscript(); } });
|
||||
this.addCommand({ id: COMMAND_IDS.generateDiagnostics, name: "Generate Diagnostics Report", callback: () => { void this.commands.generateDiagnostics(); } });
|
||||
this.addCommand({ id: COMMAND_IDS.compileManuscript, name: "Compile manuscript", callback: () => this.openCompiler() });
|
||||
this.addCommand({ id: COMMAND_IDS.compileCurrentBook, name: "Compile current book", callback: () => { void this.commands.compileCurrentBook(); } });
|
||||
this.addCommand({ id: COMMAND_IDS.compileSelectedFolder, name: "Compile selected folder", callback: () => { new FolderSuggestModal(this.app, (folder) => { void this.commands.compileFolder(folder, undefined, [], "selected-folder"); }).open(); } });
|
||||
this.addCommand({ id: COMMAND_IDS.validateManuscript, name: "Validate manuscript", callback: () => { void this.commands.validateManuscript(); } });
|
||||
this.addCommand({ id: COMMAND_IDS.generateDiagnostics, name: "Generate diagnostics report", callback: () => { void this.commands.generateDiagnostics(); } });
|
||||
}
|
||||
|
||||
private registerFolderContextMenu(): void {
|
||||
|
|
|
|||
85
src/markdown-exporter.ts
Normal file
85
src/markdown-exporter.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/** Publication-ready Markdown rendered only from the prepared semantic document. */
|
||||
import { strToU8 } from "fflate";
|
||||
import { EXPORT_FORMAT_DETAILS, type GeneratedExport, type ManuscriptExporter, type ManuscriptExportContext } from "./export-types";
|
||||
import type { SemanticBlock, SemanticDocument, SemanticInline, SemanticSection } from "./semantic-document";
|
||||
|
||||
export class MarkdownExporter implements ManuscriptExporter {
|
||||
readonly format = "markdown" as const;
|
||||
|
||||
async generate(context: ManuscriptExportContext): Promise<GeneratedExport> {
|
||||
return {
|
||||
format: this.format,
|
||||
filename: context.filename,
|
||||
mimeType: EXPORT_FORMAT_DETAILS.markdown.mimeType,
|
||||
bytes: strToU8(renderSemanticMarkdown(context.document)),
|
||||
warnings: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonical renderer used by generation and context-aware validation. */
|
||||
export function renderSemanticMarkdown(document: SemanticDocument): string {
|
||||
const output: string[] = [];
|
||||
const add = (value: string): void => {
|
||||
const clean = value.replace(/[\t ]+$/gm, "").trim();
|
||||
if (!clean) return;
|
||||
if (isHeading(clean) && isHeading(output.at(-1) ?? "") && headingText(clean) === headingText(output.at(-1) ?? "")) return;
|
||||
output.push(clean);
|
||||
};
|
||||
for (const section of document.sections) renderSection(section, add);
|
||||
return `${output.join("\n\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/g, "")}\n`;
|
||||
}
|
||||
|
||||
function renderSection(section: SemanticSection, add: (value: string) => void): void {
|
||||
const blocks = [...section.blocks];
|
||||
if (section.kind === "title") {
|
||||
const title = takeHeading(blocks, "title"); const author = takeHeading(blocks, "author");
|
||||
if (title) add(`# ${inlineText(title.inlines)}`);
|
||||
if (author && inlineText(author.inlines).trim()) add(inlineText(author.inlines));
|
||||
} else if (section.kind === "part") {
|
||||
if (section.number !== 0) addStructuralHeading(blocks, ["part-number", "part-title"], 1, add);
|
||||
} else if (section.kind === "chapter") {
|
||||
if (section.number !== 0) addStructuralHeading(blocks, ["chapter-number", "chapter-title"], 2, add);
|
||||
else removeHeadingStyles(blocks, ["chapter-number", "chapter-title"]);
|
||||
} else if (section.kind === "front-matter" || section.kind === "back-matter") {
|
||||
const style = section.kind;
|
||||
const heading = takeHeading(blocks, style);
|
||||
if (heading) add(`# ${inlineText(heading.inlines)}`);
|
||||
}
|
||||
blocks.forEach((block) => renderBlock(block, add));
|
||||
}
|
||||
|
||||
function addStructuralHeading(blocks: SemanticBlock[], styles: Array<Extract<SemanticBlock, { kind: "heading" }>["style"]>, level: number, add: (value: string) => void): void {
|
||||
const lines = styles.map((style) => takeHeading(blocks, style)).filter((block): block is Extract<SemanticBlock, { kind: "heading" }> => Boolean(block)).map((block) => inlineText(block.inlines).trim()).filter(Boolean);
|
||||
const unique = lines.filter((line, index) => lines.findIndex((other) => headingText(other) === headingText(line)) === index);
|
||||
if (unique.length) add(`${"#".repeat(level)} ${unique.join(" — ")}`);
|
||||
}
|
||||
|
||||
function renderBlock(block: SemanticBlock, add: (value: string) => void): void {
|
||||
if (block.kind === "page-break") return;
|
||||
if (block.kind === "scene-break") { add(block.text.trim() || "* * *"); return; }
|
||||
if (block.kind === "paragraph") { add(inlineText(block.inlines)); return; }
|
||||
const levels: Partial<Record<Extract<SemanticBlock, { kind: "heading" }>["style"], number>> = { "body-heading": 3, "front-matter": 1, "back-matter": 1 };
|
||||
const level = levels[block.style];
|
||||
if (level) add(`${"#".repeat(level)} ${inlineText(block.inlines)}`);
|
||||
}
|
||||
|
||||
function inlineText(inlines: SemanticInline[]): string {
|
||||
return inlines.map((inline) => {
|
||||
let value = inline.text;
|
||||
if (inline.bold && inline.italic) value = `***${value}***`;
|
||||
else if (inline.bold) value = `**${value}**`;
|
||||
else if (inline.italic) value = `*${value}*`;
|
||||
if (inline.href) value = `[${value}](${inline.href})`;
|
||||
return value;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function takeHeading(blocks: SemanticBlock[], style: Extract<SemanticBlock, { kind: "heading" }>["style"]): Extract<SemanticBlock, { kind: "heading" }> | undefined {
|
||||
const index = blocks.findIndex((block) => block.kind === "heading" && block.style === style);
|
||||
if (index < 0) return;
|
||||
return blocks.splice(index, 1)[0] as Extract<SemanticBlock, { kind: "heading" }>;
|
||||
}
|
||||
function removeHeadingStyles(blocks: SemanticBlock[], styles: Array<Extract<SemanticBlock, { kind: "heading" }>["style"]>): void { for (let index = blocks.length - 1; index >= 0; index -= 1) { const block = blocks[index]; if (block.kind === "heading" && styles.includes(block.style)) blocks.splice(index, 1); } }
|
||||
function isHeading(value: string): boolean { return /^#{1,6}\s+\S/.test(value); }
|
||||
function headingText(value: string): string { return value.replace(/^#{1,6}\s+/, "").trim().toLocaleLowerCase(); }
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
import type { DocumentMetadata } from "./model";
|
||||
import type { MetadataFilterRule, MetadataOperator } from "./settings";
|
||||
export interface FilterOperator { id: MetadataOperator; matches(actual: unknown, expected: string): boolean; }
|
||||
const normalize = (value: unknown): string => String(value ?? "").trim().toLowerCase();
|
||||
const normalize = (value: unknown): string => typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value).trim().toLowerCase() : "";
|
||||
const OPERATORS: Record<MetadataOperator, FilterOperator> = {
|
||||
equals: { id: "equals", matches: (actual, expected) => normalize(actual) === normalize(expected) },
|
||||
"not-equals": { id: "not-equals", matches: (actual, expected) => normalize(actual) !== normalize(expected) }
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export interface MatterSection {
|
|||
documents: ManuscriptDocument[];
|
||||
}
|
||||
|
||||
export interface Scene extends ManuscriptDocument {}
|
||||
export type Scene = ManuscriptDocument;
|
||||
|
||||
/** Semantic Chapter. An absent number remains absent rather than becoming zero. */
|
||||
export interface Chapter {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { strToU8, zipSync } from "fflate";
|
|||
import { createManuscriptDocx } from "./docx";
|
||||
import { EXPORT_FORMAT_DETAILS, type GeneratedExport, type ManuscriptExporter, type ManuscriptExportContext, type ExportFormat } from "./export-types";
|
||||
import type { SemanticBlock, SemanticDocument, SemanticInline, SemanticSection } from "./semantic-document";
|
||||
import { createNativePdf } from "./native-pdf";
|
||||
import { MarkdownExporter } from "./markdown-exporter";
|
||||
|
||||
const XML = `<?xml version="1.0" encoding="UTF-8"?>`;
|
||||
|
||||
|
|
@ -51,21 +51,21 @@ export class EpubExporter extends NativeExporter {
|
|||
}
|
||||
}
|
||||
|
||||
export class PdfExporter extends NativeExporter { readonly format = "pdf" as const; async generate(context: ManuscriptExportContext): Promise<GeneratedExport> { const generated = createNativePdf(context.document, context.options); return { ...this.result(context, generated.bytes), warnings: generated.warnings }; } }
|
||||
|
||||
export const EXPORTERS: Record<ExportFormat, ManuscriptExporter> = { docx: new DocxMemoryExporter(), odt: new OdtExporter(), pdf: new PdfExporter(), epub: new EpubExporter(), html: new HtmlExporter(), xml: new XmlExporter() };
|
||||
export const EXPORTERS: Record<ExportFormat, ManuscriptExporter> = { docx: new DocxMemoryExporter(), odt: new OdtExporter(), epub: new EpubExporter(), html: new HtmlExporter(), markdown: new MarkdownExporter(), xml: new XmlExporter() };
|
||||
|
||||
function odtContent(document: SemanticDocument, options: ManuscriptExportContext["options"]): string { const toc = options.tableOfContents ? `<text:table-of-content text:name="Table of Contents"><text:table-of-content-source text:outline-level="3"/><text:index-body><text:index-title text:name="Table of Contents"><text:p text:style-name="ChapterTitle">Contents</text:p></text:index-title></text:index-body></text:table-of-content>` : ""; return `${XML}<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:text>${toc}${document.sections.flatMap((section) => section.blocks).map(odtBlock).join("")}</office:text></office:body></office:document-content>`; }
|
||||
function odtBlock(block: SemanticBlock): string { if (block.kind === "page-break") return `<text:p text:style-name="PageBreak"/>`; if (block.kind === "scene-break") return `<text:p text:style-name="SceneBreak">${xml(block.text)}</text:p>`; const style = block.kind === "paragraph" ? block.first ? "FirstParagraph" : "BodyText" : odtStyle(block.style); const pageBreak = block.kind === "heading" && block.pageBreakBefore ? `<text:p text:style-name="PageBreak"/>` : ""; return `${pageBreak}<text:p text:style-name="${style}">${odtInlines(block.inlines)}</text:p>`; }
|
||||
function odtInlines(inlines: SemanticInline[]): string { return inlines.map((item) => item.bold || item.italic ? `<text:span text:style-name="${item.bold && item.italic ? "BoldItalic" : item.bold ? "Bold" : "Italic"}">${xml(item.text)}</text:span>` : xml(item.text)).join(""); }
|
||||
function odtStyle(style: Extract<SemanticBlock, { kind: "heading" }>["style"]): string { return ({ title: "Title", author: "Author", "front-matter": "FrontMatterHeading", "back-matter": "BackMatterHeading", "part-number": "PartNumber", "part-title": "PartTitle", "chapter-number": "ChapterNumber", "chapter-title": "ChapterTitle", "body-heading": "BodyHeading" })[style]; }
|
||||
function odtStyles(options: ManuscriptExportContext["options"]): string { const page = options.pageSize === "letter" ? ["21.59cm", "27.94cm"] : ["21cm", "29.7cm"]; const names = ["Title", "Author", "FrontMatterHeading", "BackMatterHeading", "PartNumber", "PartTitle", "ChapterNumber", "ChapterTitle", "BodyHeading", "FirstParagraph", "BodyText", "SceneBreak", "PageBreak"]; return `${XML}<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"><office:styles>${names.map((name) => `<style:style style:name="${name}" style:family="paragraph"><style:paragraph-properties fo:line-height="${options.lineSpacing * 100}%" fo:text-indent="${name === "BodyText" ? options.firstLineIndentCm : 0}cm"${name === "PageBreak" ? ` fo:break-before="page"` : ""}${/Heading|Number|Title|Author|SceneBreak/.test(name) ? ` fo:text-align="center"` : ""}/><style:text-properties style:font-name="${xmlAttr(options.font)}" fo:font-size="${options.fontSize}pt"${/Heading|Number|Title/.test(name) ? ` fo:font-weight="bold"` : ""}/></style:style>`).join("")}<style:style style:name="Bold" style:family="text"><style:text-properties fo:font-weight="bold"/></style:style><style:style style:name="Italic" style:family="text"><style:text-properties fo:font-style="italic"/></style:style><style:style style:name="BoldItalic" style:family="text"><style:text-properties fo:font-weight="bold" fo:font-style="italic"/></style:style></office:styles><office:automatic-styles><style:page-layout style:name="Page"><style:page-layout-properties fo:page-width="${page[0]}" fo:page-height="${page[1]}" fo:margin="2.54cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="Page"/></office:master-styles></office:document-styles>`; }
|
||||
function odtStyles(options: ManuscriptExportContext["options"]): string { const page = options.pageSize === "letter" ? ["21.59cm", "27.94cm"] : ["21cm", "29.7cm"]; const names = ["Title", "Author", "FrontMatterHeading", "BackMatterHeading", "PartNumber", "PartTitle", "ChapterNumber", "ChapterTitle", "BodyHeading", "FirstParagraph", "BodyText", "SceneBreak", "PageBreak"]; const boldStyles = new Set(["Title", "FrontMatterHeading", "BackMatterHeading", "PartNumber", "PartTitle", "ChapterNumber", "ChapterTitle", "BodyHeading"]); return `${XML}<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"><office:styles>${names.map((name) => `<style:style style:name="${name}" style:family="paragraph"><style:paragraph-properties fo:line-height="${options.lineSpacing * 100}%" fo:text-indent="${name === "BodyText" && options.indentParagraphs ? options.firstLineIndentCm : 0}cm"${name === "PageBreak" ? ` fo:break-before="page"` : ""}${/Heading|Number|Title|Author|SceneBreak/.test(name) ? ` fo:text-align="center"` : ""}/><style:text-properties style:font-name="${xmlAttr(options.font)}" fo:font-size="${options.fontSize}pt"${boldStyles.has(name) ? ` fo:font-weight="bold"` : ""}/></style:style>`).join("")}<style:style style:name="Bold" style:family="text"><style:text-properties fo:font-weight="bold"/></style:style><style:style style:name="Italic" style:family="text"><style:text-properties fo:font-style="italic"/></style:style><style:style style:name="BoldItalic" style:family="text"><style:text-properties fo:font-weight="bold" fo:font-style="italic"/></style:style></office:styles><office:automatic-styles><style:page-layout style:name="Page"><style:page-layout-properties fo:page-width="${page[0]}" fo:page-height="${page[1]}" fo:margin="2.54cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="Page"/></office:master-styles></office:document-styles>`; }
|
||||
|
||||
function htmlDocument(document: SemanticDocument, options: ManuscriptExportContext["options"]): string { const toc = options.tableOfContents ? `<nav class="table-of-contents" aria-label="Contents"><h1>Contents</h1><ol>${document.sections.filter((section) => section.kind === "part" || section.kind === "chapter").map((section) => `<li><a href="#${htmlAttr(section.id)}">${html(section.title)}</a></li>`).join("")}</ol></nav>` : ""; return `<!doctype html><html lang="${htmlAttr(document.language)}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${html(document.title)}</title><style>${reflowableCss(options)}</style></head><body>${toc}<main>${document.sections.map(htmlSection).join("")}</main></body></html>`; }
|
||||
function htmlSection(section: SemanticSection): string { return `<section id="${htmlAttr(section.id)}" class="${htmlAttr(section.kind)}">${section.blocks.map(htmlBlock).join("")}</section>`; }
|
||||
function htmlBlock(block: SemanticBlock): string { if (block.kind === "page-break") return `<hr class="page-break">`; if (block.kind === "scene-break") return `<div class="scene-break" role="separator">${html(block.text)}</div>`; const content = htmlInlines(block.inlines); if (block.kind === "paragraph") return `<p class="${block.first ? "first-paragraph" : "body-text"}">${content}</p>`; const level = block.style.startsWith("part") ? 1 : block.style.startsWith("chapter") ? 2 : 2; return `<h${level} class="${block.style}">${content}</h${level}>`; }
|
||||
function htmlSection(section: SemanticSection): string { return `<section id="${htmlAttr(section.id)}" class="${htmlAttr(section.kind)}">${htmlBlocks(section.blocks)}</section>`; }
|
||||
function htmlBlocks(blocks: SemanticBlock[]): string { const output: string[] = []; for (let index = 0; index < blocks.length; index += 1) { const block = blocks[index]; const next = blocks[index + 1]; if (block.kind === "heading" && next?.kind === "heading" && block.style === "part-number" && next.style === "part-title") { output.push(htmlCombinedHeading(1, "manuscript-part-heading", block, next)); index += 1; } else if (block.kind === "heading" && next?.kind === "heading" && block.style === "chapter-number" && next.style === "chapter-title") { output.push(htmlCombinedHeading(2, "manuscript-chapter-heading", block, next)); index += 1; } else output.push(htmlBlock(block)); } return output.join(""); }
|
||||
function htmlCombinedHeading(level: number, className: string, first: Extract<SemanticBlock, { kind: "heading" }>, second: Extract<SemanticBlock, { kind: "heading" }>): string { return `<h${level} class="${className}">${htmlInlines(first.inlines)} — ${htmlInlines(second.inlines)}</h${level}>`; }
|
||||
function htmlBlock(block: SemanticBlock): string { if (block.kind === "page-break") return `<hr class="page-break">`; if (block.kind === "scene-break") return `<div class="scene-break" role="separator">${html(block.text)}</div>`; const content = htmlInlines(block.inlines); if (block.kind === "paragraph") return `<p class="${block.first ? "first-paragraph" : "body-text"}">${content}</p>`; const presentation = ({ title: [1, "manuscript-title"], author: [2, "manuscript-author"], "front-matter": [2, "manuscript-matter-heading"], "back-matter": [2, "manuscript-matter-heading"], "part-number": [1, "manuscript-part-number"], "part-title": [1, "manuscript-part-title"], "chapter-number": [2, "manuscript-chapter-number"], "chapter-title": [2, "manuscript-chapter-title"], "body-heading": [3, "manuscript-body-heading"] } as const)[block.style]; return `<h${presentation[0]} class="${presentation[1]}">${content}</h${presentation[0]}>`; }
|
||||
function htmlInlines(inlines: SemanticInline[]): string { return inlines.map((item) => { let value = html(item.text); if (item.italic) value = `<em>${value}</em>`; if (item.bold) value = `<strong>${value}</strong>`; return value; }).join(""); }
|
||||
function reflowableCss(options: ManuscriptExportContext["options"]): string { return `:root{font-family:${cssString(options.font)},serif;font-size:${options.fontSize}pt;line-height:${options.lineSpacing}}body{max-width:48rem;margin:2rem auto;padding:0 1rem;color:#111;background:#fff}.title-page,.part{text-align:center;break-before:page}.chapter${options.chapterPageBreak ? "{break-before:page}" : "{}"}.first-paragraph{text-indent:0}.body-text{text-indent:${options.firstLineIndentCm}cm;margin:0}.scene-break{text-align:center;margin:1.5em 0}h1,h2{break-after:avoid}.front-matter,.back-matter{break-before:page}`; }
|
||||
function reflowableCss(options: ManuscriptExportContext["options"]): string { return `:root{font-family:${cssString(options.font)},serif;font-size:${options.fontSize}pt;line-height:${options.lineSpacing}}body{max-width:48rem;margin:2rem auto;padding:0 1rem;color:#111;background:#fff}.title-page,.title,.part{text-align:center;break-before:page}.chapter${options.chapterPageBreak ? "{break-before:page}" : "{}"}.first-paragraph{text-indent:0}.body-text{text-indent:${options.indentParagraphs ? options.firstLineIndentCm : 0}cm;margin:0}.scene-break{text-align:center;margin:1.5em 0}.manuscript-title,.manuscript-matter-heading,.manuscript-part-number,.manuscript-part-title,.manuscript-part-heading,.manuscript-chapter-number,.manuscript-chapter-title,.manuscript-chapter-heading{font-weight:700;break-after:avoid}.front-matter,.back-matter{break-before:page}`; }
|
||||
function xhtmlSection(document: SemanticDocument, section: SemanticSection, options: ManuscriptExportContext["options"]): string { return `${XML}<html xmlns="http://www.w3.org/1999/xhtml" lang="${xmlAttr(document.language)}"><head><title>${xml(section.title)}</title><link rel="stylesheet" type="text/css" href="style.css"/></head><body><main>${htmlSection(section)}</main></body></html>`; }
|
||||
|
||||
function xmlDocument(document: SemanticDocument): string {
|
||||
|
|
|
|||
|
|
@ -1,200 +0,0 @@
|
|||
/** Dependency-free PDF generation using the built-in WinAnsi font repertoire. */
|
||||
import type { CompileWarning } from "./model";
|
||||
import { plainText, type SemanticBlock, type SemanticDocument } from "./semantic-document";
|
||||
import type { ExportFormattingOptions } from "./export-types";
|
||||
|
||||
interface EncodedPdfText { display: string; bytes: Uint8Array; }
|
||||
type PdfBaseFont = "Helvetica" | "Times-Roman";
|
||||
type PdfLineRole = "title" | "author" | "part-number" | "part-title" | "chapter-number" | "chapter-title" | "matter-heading" | "body-heading" | "body" | "scene-break";
|
||||
export interface PdfPlacedLine { text: string; x: number; y: number; width: number; size: number; leading: number; role: PdfLineRole; bold: boolean; firstLineIndent: number; }
|
||||
export interface PdfPageLayout { lines: PdfPlacedLine[]; }
|
||||
export interface PdfLayoutMetadata { pageWidth: number; pageHeight: number; leftMargin: number; rightMargin: number; topMargin: number; bottomMargin: number; usableWidth: number; pages: PdfPageLayout[]; }
|
||||
export interface NativePdfResult { bytes: Uint8Array; warnings: CompileWarning[]; layout: PdfLayoutMetadata; }
|
||||
export interface InspectedPdfTextLine { text: string; size: number; leading: number; x: number; y: number; bold: boolean; }
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const WIN_ANSI_SPECIAL = new Map<number, number>([
|
||||
[0x20ac, 0x80], [0x201a, 0x82], [0x0192, 0x83], [0x201e, 0x84], [0x2026, 0x85], [0x2020, 0x86], [0x2021, 0x87],
|
||||
[0x02c6, 0x88], [0x2030, 0x89], [0x0160, 0x8a], [0x2039, 0x8b], [0x0152, 0x8c], [0x017d, 0x8e], [0x2018, 0x91],
|
||||
[0x2019, 0x92], [0x201c, 0x93], [0x201d, 0x94], [0x2022, 0x95], [0x2013, 0x96], [0x2014, 0x97], [0x02dc, 0x98],
|
||||
[0x2122, 0x99], [0x0161, 0x9a], [0x203a, 0x9b], [0x0153, 0x9c], [0x017e, 0x9e], [0x0178, 0x9f]
|
||||
]);
|
||||
|
||||
export function createNativePdf(document: SemanticDocument, options: ExportFormattingOptions): NativePdfResult {
|
||||
const unsupported = new Set<number>();
|
||||
const normalise = (value: string): string => encodeWinAnsi(value, unsupported).display;
|
||||
const dimensions = pdfPageDimensions(options.pageSize); const margin = clamp(pdfPointsFromCm(options.pageMarginCm), 28, dimensions.width / 3);
|
||||
const font = pdfBaseFont(options.font); const layout = layoutPdf(document, options, font, normalise, dimensions.width, dimensions.height, margin);
|
||||
|
||||
const mappings = new Map<number, number>();
|
||||
const encodedPages = layout.pages.map((page) => page.lines.map((line) => {
|
||||
const encoded = encodeWinAnsi(line.text, unsupported);
|
||||
encoded.bytes.forEach((byte) => mappings.set(byte, unicodeForWinAnsi(byte)));
|
||||
return { ...line, encoded: encoded.bytes };
|
||||
}));
|
||||
const pageRefs = encodedPages.map((_page, index) => `${6 + index * 2} 0 R`);
|
||||
const cmap = toUnicodeCMap(mappings);
|
||||
const objects: Uint8Array[] = [
|
||||
ascii(`<< /Type /Catalog /Pages 2 0 R >>`),
|
||||
ascii(`<< /Type /Pages /Kids [${pageRefs.join(" ")}] /Count ${encodedPages.length} >>`),
|
||||
ascii(`<< /Type /Font /Subtype /Type1 /BaseFont /${font} /Encoding /WinAnsiEncoding /ToUnicode 5 0 R >>`),
|
||||
ascii(`<< /Type /Font /Subtype /Type1 /BaseFont /${font === "Times-Roman" ? "Times-Bold" : "Helvetica-Bold"} /Encoding /WinAnsiEncoding /ToUnicode 5 0 R >>`),
|
||||
streamObject(ascii(cmap))
|
||||
];
|
||||
encodedPages.forEach((page, index) => {
|
||||
const pageObject = 6 + index * 2; const contentObject = pageObject + 1;
|
||||
const commands = page.map((line) => `BT /F${line.bold ? "2" : "1"} ${line.size.toFixed(2)} Tf ${line.leading.toFixed(2)} TL ${line.x.toFixed(2)} ${line.y.toFixed(2)} Td 0 Tr <${hex(line.encoded)}> Tj ET`).join("\n");
|
||||
objects.push(ascii(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${layout.pageWidth.toFixed(2)} ${layout.pageHeight.toFixed(2)}] /MCLayout << /LeftMargin ${layout.leftMargin.toFixed(2)} /RightMargin ${layout.rightMargin.toFixed(2)} /TopMargin ${layout.topMargin.toFixed(2)} /BottomMargin ${layout.bottomMargin.toFixed(2)} /UsableWidth ${layout.usableWidth.toFixed(2)} >> /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${contentObject} 0 R >>`));
|
||||
objects.push(streamObject(ascii(commands)));
|
||||
});
|
||||
const warnings: CompileWarning[] = unsupported.size ? [{ severity: "information", code: "pdf-unsupported-glyphs", message: `${unsupported.size} unsupported PDF character${unsupported.size === 1 ? " was" : "s were"} replaced with an intentional fallback glyph.` }] : [];
|
||||
return { bytes: assemblePdf(objects), warnings, layout };
|
||||
}
|
||||
|
||||
export function pdfPointsFromCm(value: number): number { return value * 72 / 2.54; }
|
||||
export function pdfPointsFromMm(value: number): number { return value * 72 / 25.4; }
|
||||
export function pdfPageDimensions(size: ExportFormattingOptions["pageSize"]): { width: number; height: number } { return size === "letter" ? { width: 612, height: 792 } : { width: pdfPointsFromMm(210), height: pdfPointsFromMm(297) }; }
|
||||
|
||||
export function measureNativePdfText(value: string, font: PdfBaseFont, size: number, bold = false): number {
|
||||
const encoded = encodeWinAnsi(value, new Set()).bytes; let units = 0; encoded.forEach((byte) => { units += glyphWidth(byte, font, bold); }); return units / 1000 * size;
|
||||
}
|
||||
|
||||
export function wrapNativePdfText(value: string, font: PdfBaseFont, size: number, firstWidth: number, laterWidth = firstWidth, bold = false): string[] {
|
||||
const normal = encodeWinAnsi(value, new Set()).display.trim().replace(/\s+/g, " "); if (!normal) return [""];
|
||||
const words = normal.split(" "); const lines: string[] = []; let line = ""; let lineWidth = 0; const spaceWidth = measureNativePdfText(" ", font, size, bold);
|
||||
const widthForLine = (): number => lines.length === 0 ? firstWidth : laterWidth;
|
||||
const pushLongWord = (word: string): void => { let part = ""; let partWidth = 0; for (const character of word) { const characterWidth = measureNativePdfText(character, font, size, bold); if (part && partWidth + characterWidth > widthForLine()) { lines.push(part); part = character; partWidth = characterWidth; } else { part += character; partWidth += characterWidth; } } line = part; lineWidth = partWidth; };
|
||||
for (const word of words) { const wordWidth = measureNativePdfText(word, font, size, bold); const candidateWidth = line ? lineWidth + spaceWidth + wordWidth : wordWidth; if (candidateWidth <= widthForLine()) { line = line ? `${line} ${word}` : word; lineWidth = candidateWidth; continue; } if (line) { lines.push(line); line = ""; lineWidth = 0; } if (wordWidth > widthForLine()) pushLongWord(word); else { line = word; lineWidth = wordWidth; } }
|
||||
if (line || !lines.length) lines.push(line); return lines;
|
||||
}
|
||||
|
||||
function layoutPdf(document: SemanticDocument, options: ExportFormattingOptions, font: PdfBaseFont, normalise: (value: string) => string, pageWidth: number, pageHeight: number, margin: number): PdfLayoutMetadata {
|
||||
const usableWidth = pageWidth - margin - margin; const pages: PdfPageLayout[] = [{ lines: [] }]; let y = pageHeight - margin;
|
||||
const blocks = document.sections.flatMap((section) => section.blocks); const indent = pdfPointsFromCm(options.firstLineIndentCm);
|
||||
const newPage = (): void => { if (pages.at(-1)!.lines.length) pages.push({ lines: [] }); y = pageHeight - margin; };
|
||||
const remaining = (): number => y - margin;
|
||||
const place = (text: string, style: PdfBlockStyle, firstLineIndent = 0): void => { const width = measureNativePdfText(text, font, style.size, style.bold); const x = style.align === "center" ? margin + Math.max(0, (usableWidth - width) / 2) : margin + firstLineIndent; pages.at(-1)!.lines.push({ text, x, y, width, size: style.size, leading: style.leading, role: style.role, bold: style.bold, firstLineIndent }); y -= style.leading; };
|
||||
for (let index = 0; index < blocks.length; index += 1) {
|
||||
const block = blocks[index]; if (block.kind === "page-break") { newPage(); continue; }
|
||||
const style = pdfBlockStyle(block, options); const text = normalise(plainText(block));
|
||||
if (block.kind === "heading" && block.pageBreakBefore) newPage();
|
||||
if (block.kind === "heading" && remaining() < keepGroupHeight(blocks, index, options, font, usableWidth, indent, normalise)) newPage();
|
||||
if (block.kind === "scene-break" && remaining() < sceneKeepHeight(blocks, index, options, font, usableWidth, indent, normalise)) newPage();
|
||||
y -= style.before;
|
||||
if (block.kind === "paragraph") {
|
||||
const firstIndent = block.first ? 0 : indent; const wrapped = wrapNativePdfText(text, font, style.size, usableWidth - firstIndent, usableWidth, style.bold);
|
||||
wrapped.forEach((line, lineIndex) => { if (remaining() < style.leading) newPage(); place(line, style, lineIndex === 0 ? firstIndent : 0); });
|
||||
} else {
|
||||
const wrapped = wrapNativePdfText(text, font, style.size, usableWidth, usableWidth, style.bold);
|
||||
const needed = wrapped.length * style.leading + style.after; if (remaining() < needed) newPage();
|
||||
wrapped.forEach((line) => place(line, style));
|
||||
}
|
||||
y -= style.after;
|
||||
if (block.kind === "heading" && block.pageBreakAfter) newPage();
|
||||
}
|
||||
if (!pages[0].lines.length) { const style = pdfBlockStyle({ kind: "heading", style: "title", inlines: [{ text: document.title }] }, options); place(normalise(document.title), style); }
|
||||
return { pageWidth, pageHeight, leftMargin: margin, rightMargin: margin, topMargin: margin, bottomMargin: margin, usableWidth, pages };
|
||||
}
|
||||
|
||||
interface PdfBlockStyle { size: number; leading: number; before: number; after: number; align: "left" | "center"; role: PdfLineRole; bold: boolean; }
|
||||
function pdfBlockStyle(block: Exclude<SemanticBlock, { kind: "page-break" }>, options: ExportFormattingOptions): PdfBlockStyle {
|
||||
const bodyLeading = options.fontSize * options.lineSpacing;
|
||||
if (block.kind === "paragraph") return { size: options.fontSize, leading: bodyLeading, before: 0, after: 0, align: "left", role: "body", bold: false };
|
||||
if (block.kind === "scene-break") return { size: options.fontSize, leading: bodyLeading, before: bodyLeading * 0.5, after: bodyLeading * 0.5, align: "center", role: "scene-break", bold: false };
|
||||
const styles: Record<Extract<SemanticBlock, { kind: "heading" }>["style"], PdfBlockStyle> = {
|
||||
title: { size: 24, leading: 30, before: 108, after: 0, align: "center", role: "title", bold: true },
|
||||
author: { size: 14, leading: 20, before: 24, after: 24, align: "center", role: "author", bold: false },
|
||||
"part-number": { size: 14, leading: 19, before: 72, after: 14, align: "center", role: "part-number", bold: true },
|
||||
"part-title": { size: 20, leading: 25, before: 0, after: 36, align: "center", role: "part-title", bold: true },
|
||||
"chapter-number": { size: 13, leading: 18, before: 48, after: 12, align: "center", role: "chapter-number", bold: true },
|
||||
"chapter-title": { size: 18, leading: 23, before: 0, after: 30, align: "center", role: "chapter-title", bold: true },
|
||||
"front-matter": { size: 18, leading: 23, before: 30, after: 24, align: "center", role: "matter-heading", bold: true },
|
||||
"back-matter": { size: 18, leading: 23, before: 30, after: 24, align: "center", role: "matter-heading", bold: true },
|
||||
"body-heading": { size: 15, leading: 20, before: 18, after: 12, align: "center", role: "body-heading", bold: true }
|
||||
};
|
||||
return styles[block.style];
|
||||
}
|
||||
|
||||
function keepGroupHeight(blocks: SemanticBlock[], index: number, options: ExportFormattingOptions, font: PdfBaseFont, usableWidth: number, indent: number, normalise: (value: string) => string): number {
|
||||
let total = 0; const first = blocks[index]; if (first.kind !== "heading") return 0;
|
||||
const stylesToKeep = new Set(["title", "part-number", "chapter-number"]); const limit = stylesToKeep.has(first.style) ? 3 : 2;
|
||||
for (let offset = 0; offset < limit && index + offset < blocks.length; offset += 1) { const block = blocks[index + offset]; if (block.kind === "page-break") break; const style = pdfBlockStyle(block, options); const text = normalise(plainText(block)); if (block.kind === "paragraph") { const firstIndent = block.first ? 0 : indent; total += style.before + style.leading + style.after; if (!wrapNativePdfText(text, font, style.size, usableWidth - firstIndent, usableWidth, style.bold).length) break; break; } const lines = wrapNativePdfText(text, font, style.size, usableWidth, usableWidth, style.bold); total += style.before + lines.length * style.leading + style.after; if (block.kind === "scene-break") break; }
|
||||
return total;
|
||||
}
|
||||
function sceneKeepHeight(blocks: SemanticBlock[], index: number, options: ExportFormattingOptions, font: PdfBaseFont, usableWidth: number, indent: number, normalise: (value: string) => string): number { const scene = blocks[index]; if (scene.kind !== "scene-break") return 0; const style = pdfBlockStyle(scene, options); let total = style.before + style.leading + style.after; const next = blocks[index + 1]; if (next?.kind === "paragraph") { const nextStyle = pdfBlockStyle(next, options); const firstIndent = next.first ? 0 : indent; if (wrapNativePdfText(normalise(plainText(next)), font, nextStyle.size, usableWidth - firstIndent, usableWidth, nextStyle.bold).length) total += nextStyle.before + nextStyle.leading; } return total; }
|
||||
|
||||
/** Recover generated text through the same ToUnicode mapping a PDF viewer uses. */
|
||||
export function recoverNativePdfText(bytes: Uint8Array): string {
|
||||
return inspectNativePdfTextLines(bytes).map((line) => line.text).join("\n");
|
||||
}
|
||||
|
||||
export function inspectNativePdfTextLines(bytes: Uint8Array): InspectedPdfTextLine[] {
|
||||
const source = new TextDecoder("latin1").decode(bytes); const mapping = new Map<number, string>();
|
||||
for (const match of source.matchAll(/<([0-9A-F]{2})>\s*<([0-9A-F]{4}(?:[0-9A-F]{4})?)>/g)) mapping.set(Number.parseInt(match[1], 16), utf16BeHexToString(match[2]));
|
||||
const output: InspectedPdfTextLine[] = [];
|
||||
for (const match of source.matchAll(/BT \/F([12]) ([\d.]+) Tf ([\d.]+) TL ([\d.]+) ([\d.]+) Td 0 Tr <([0-9A-F]*)> Tj ET/g)) { const value = match[6]; if (value.length % 2) return []; let text = ""; for (let index = 0; index < value.length; index += 2) { const character = mapping.get(Number.parseInt(value.slice(index, index + 2), 16)); if (character === undefined) return []; text += character; } output.push({ text, size: Number(match[2]), leading: Number(match[3]), x: Number(match[4]), y: Number(match[5]), bold: match[1] === "2" }); }
|
||||
return output;
|
||||
}
|
||||
|
||||
export function escapePdfLiteral(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\x08/g, "\\b").replace(/\f/g, "\\f");
|
||||
}
|
||||
|
||||
function encodeWinAnsi(value: string, unsupported: Set<number>): EncodedPdfText {
|
||||
const bytes: number[] = []; const display: string[] = [];
|
||||
for (const character of cleanPdfText(value.normalize("NFC"))) {
|
||||
const point = character.codePointAt(0)!; const byte = winAnsiByte(point);
|
||||
if (byte === undefined) { unsupported.add(point); bytes.push(0x3f); display.push("?"); } else { bytes.push(byte); display.push(character); }
|
||||
}
|
||||
return { display: display.join(""), bytes: Uint8Array.from(bytes) };
|
||||
}
|
||||
function winAnsiByte(point: number): number | undefined { if (point <= 0x7f || point >= 0xa0 && point <= 0xff) return point; return WIN_ANSI_SPECIAL.get(point); }
|
||||
function unicodeForWinAnsi(byte: number): number { for (const [point, encoded] of WIN_ANSI_SPECIAL) if (encoded === byte) return point; return byte; }
|
||||
function cleanPdfText(value: string): string { return Array.from(value).filter((character) => { const point = character.codePointAt(0)!; return point === 9 || point === 10 || point === 13 || point >= 32; }).join("").replace(/[\t\r\n]+/g, " "); }
|
||||
function pdfBaseFont(value: string): PdfBaseFont { return /arial|helvetica|sans/i.test(value) ? "Helvetica" : "Times-Roman"; }
|
||||
function rawGlyphWidth(byte: number, font: PdfBaseFont, bold: boolean): number {
|
||||
const unicode = unicodeForWinAnsi(byte); const base = baseLatinCharacter(unicode); const point = base.codePointAt(0) ?? 0x3f;
|
||||
if (font === "Helvetica") {
|
||||
if (point >= 0x30 && point <= 0x39) return 556;
|
||||
if (point >= 0x41 && point <= 0x5a) return (bold ? HELVETICA_BOLD_UPPER : HELVETICA_UPPER)[point - 0x41];
|
||||
if (point >= 0x61 && point <= 0x7a) return (bold ? HELVETICA_BOLD_LOWER : HELVETICA_LOWER)[point - 0x61];
|
||||
return (bold ? HELVETICA_BOLD_SPECIAL : HELVETICA_SPECIAL).get(unicode) ?? (bold ? HELVETICA_BOLD_SPECIAL : HELVETICA_SPECIAL).get(point) ?? 556;
|
||||
}
|
||||
if (point >= 0x30 && point <= 0x39) return 500;
|
||||
if (point >= 0x41 && point <= 0x5a) return (bold ? TIMES_BOLD_UPPER : TIMES_UPPER)[point - 0x41];
|
||||
if (point >= 0x61 && point <= 0x7a) return (bold ? TIMES_BOLD_LOWER : TIMES_LOWER)[point - 0x61];
|
||||
return (bold ? TIMES_BOLD_SPECIAL : TIMES_SPECIAL).get(unicode) ?? (bold ? TIMES_BOLD_SPECIAL : TIMES_SPECIAL).get(point) ?? 500;
|
||||
}
|
||||
function baseLatinCharacter(point: number): string { const value = String.fromCodePoint(point).normalize("NFD").replace(/[\u0300-\u036f]/g, ""); if (/^[A-Za-z]$/.test(value)) return value; if (point === 0x00d8) return "O"; if (point === 0x00f8) return "o"; if (point === 0x00c6) return "A"; if (point === 0x00e6) return "a"; if (point === 0x0152) return "O"; if (point === 0x0153) return "o"; if (point === 0x00d0 || point === 0x00de) return "D"; if (point === 0x00f0 || point === 0x00fe) return "d"; return String.fromCodePoint(point); }
|
||||
const TIMES_UPPER = [722, 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722, 556, 722, 667, 556, 611, 722, 722, 944, 722, 722, 611];
|
||||
const TIMES_LOWER = [444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500, 500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444];
|
||||
const TIMES_BOLD_UPPER = [722, 667, 722, 722, 667, 611, 778, 778, 389, 500, 778, 667, 944, 722, 778, 611, 778, 722, 556, 667, 722, 722, 1000, 722, 722, 667];
|
||||
const TIMES_BOLD_LOWER = [500, 556, 444, 556, 444, 333, 500, 556, 278, 333, 556, 278, 833, 556, 500, 556, 556, 444, 389, 333, 556, 500, 722, 500, 500, 444];
|
||||
const HELVETICA_UPPER = [667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611];
|
||||
const HELVETICA_LOWER = [556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500];
|
||||
const HELVETICA_BOLD_UPPER = [722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611];
|
||||
const HELVETICA_BOLD_LOWER = [556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500];
|
||||
const TIMES_SPECIAL = new Map<number, number>([[0x20, 250], [0x21, 333], [0x22, 408], [0x23, 500], [0x24, 500], [0x25, 833], [0x26, 778], [0x27, 180], [0x28, 333], [0x29, 333], [0x2a, 500], [0x2b, 564], [0x2c, 250], [0x2d, 333], [0x2e, 250], [0x2f, 278], [0x3a, 278], [0x3b, 278], [0x3c, 564], [0x3d, 564], [0x3e, 564], [0x3f, 444], [0x40, 921], [0x5b, 333], [0x5c, 278], [0x5d, 333], [0x5e, 469], [0x5f, 500], [0x60, 333], [0x7b, 480], [0x7c, 200], [0x7d, 480], [0x7e, 541], [0x2018, 333], [0x2019, 333], [0x201c, 444], [0x201d, 444], [0x2013, 500], [0x2014, 1000], [0x2026, 1000], [0x2022, 350], [0x20ac, 500], [0x00a3, 500], [0x00a5, 500], [0x00a9, 760], [0x00ae, 760], [0x2122, 980], [0x00df, 500]]);
|
||||
const HELVETICA_SPECIAL = new Map<number, number>([[0x20, 278], [0x21, 278], [0x22, 355], [0x23, 556], [0x24, 556], [0x25, 889], [0x26, 667], [0x27, 191], [0x28, 333], [0x29, 333], [0x2a, 389], [0x2b, 584], [0x2c, 278], [0x2d, 333], [0x2e, 278], [0x2f, 278], [0x3a, 278], [0x3b, 278], [0x3c, 584], [0x3d, 584], [0x3e, 584], [0x3f, 556], [0x40, 1015], [0x5b, 278], [0x5c, 278], [0x5d, 278], [0x5e, 469], [0x5f, 556], [0x60, 333], [0x7b, 334], [0x7c, 260], [0x7d, 334], [0x7e, 584], [0x2018, 222], [0x2019, 222], [0x201c, 333], [0x201d, 333], [0x2013, 556], [0x2014, 1000], [0x2026, 1000], [0x2022, 350], [0x20ac, 556], [0x00a3, 556], [0x00a5, 556], [0x00a9, 737], [0x00ae, 737], [0x2122, 1000], [0x00df, 611]]);
|
||||
const TIMES_BOLD_SPECIAL = new Map(TIMES_SPECIAL); TIMES_BOLD_SPECIAL.set(0x22, 555).set(0x27, 278).set(0x3f, 500).set(0x2018, 333).set(0x2019, 333).set(0x201c, 555).set(0x201d, 555);
|
||||
const HELVETICA_BOLD_SPECIAL = new Map(HELVETICA_SPECIAL); HELVETICA_BOLD_SPECIAL.set(0x22, 474).set(0x27, 238).set(0x3f, 611).set(0x2018, 278).set(0x2019, 278).set(0x201c, 500).set(0x201d, 500);
|
||||
const PDF_WIDTHS = { "Times-Roman": { normal: widthTable("Times-Roman", false), bold: widthTable("Times-Roman", true) }, Helvetica: { normal: widthTable("Helvetica", false), bold: widthTable("Helvetica", true) } };
|
||||
function widthTable(font: PdfBaseFont, bold: boolean): Uint16Array { return Uint16Array.from({ length: 256 }, (_value, byte) => rawGlyphWidth(byte, font, bold)); }
|
||||
function glyphWidth(byte: number, font: PdfBaseFont, bold: boolean): number { return PDF_WIDTHS[font][bold ? "bold" : "normal"][byte]; }
|
||||
function clamp(value: number, minimum: number, maximum: number): number { return Math.min(maximum, Math.max(minimum, Number.isFinite(value) ? value : minimum)); }
|
||||
function hex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0").toUpperCase()).join(""); }
|
||||
function unicodeHex(point: number): string { if (point <= 0xffff) return point.toString(16).padStart(4, "0").toUpperCase(); const adjusted = point - 0x10000; return `${(0xd800 + (adjusted >> 10)).toString(16).toUpperCase()}${(0xdc00 + (adjusted & 0x3ff)).toString(16).toUpperCase()}`; }
|
||||
function toUnicodeCMap(mappings: ReadonlyMap<number, number>): string {
|
||||
const entries = [...mappings].sort(([left], [right]) => left - right);
|
||||
const groups: string[] = []; for (let index = 0; index < entries.length; index += 100) { const chunk = entries.slice(index, index + 100); groups.push(`${chunk.length} beginbfchar\n${chunk.map(([byte, point]) => `<${byte.toString(16).padStart(2, "0").toUpperCase()}> <${unicodeHex(point)}>`).join("\n")}\nendbfchar`); }
|
||||
return `/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /ManuscriptCompiler-WinAnsi-UCS def\n/CMapType 2 def\n1 begincodespacerange\n<00> <FF>\nendcodespacerange\n${groups.join("\n")}\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend`;
|
||||
}
|
||||
function utf16BeHexToString(value: string): string { const units: number[] = []; for (let index = 0; index < value.length; index += 4) units.push(Number.parseInt(value.slice(index, index + 4), 16)); return String.fromCharCode(...units); }
|
||||
function ascii(value: string): Uint8Array { return encoder.encode(value); }
|
||||
function streamObject(stream: Uint8Array): Uint8Array { return concatBytes([ascii(`<< /Length ${stream.byteLength} >>\nstream\n`), stream, ascii(`\nendstream`)]); }
|
||||
function assemblePdf(objects: readonly Uint8Array[]): Uint8Array {
|
||||
const header = Uint8Array.from([...ascii("%PDF-1.7\n%"), 0xe2, 0xe3, 0xcf, 0xd3, 0x0a]); const chunks: Uint8Array[] = [header]; const offsets: number[] = []; let offset = header.byteLength;
|
||||
objects.forEach((object, index) => { const chunk = concatBytes([ascii(`${index + 1} 0 obj\n`), object, ascii(`\nendobj\n`)]); offsets.push(offset); offset += chunk.byteLength; chunks.push(chunk); });
|
||||
const xrefOffset = offset; const xref = ascii(`xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.map((value) => `${String(value).padStart(10, "0")} 00000 n `).join("\n")}\ntrailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`); chunks.push(xref); return concatBytes(chunks);
|
||||
}
|
||||
function concatBytes(chunks: readonly Uint8Array[]): Uint8Array { const length = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); const output = new Uint8Array(length); let offset = 0; for (const chunk of chunks) { output.set(chunk, offset); offset += chunk.byteLength; } return output; }
|
||||
|
|
@ -41,7 +41,7 @@ export class ManuscriptParser {
|
|||
const documents = (files: TFile[]): ManuscriptDocument[] => files.map((file) => cache.get(file.path)).filter((document): document is ManuscriptDocument => document !== undefined);
|
||||
const frontDocuments = documents(scan.frontMatter);
|
||||
const backDocuments = documents(scan.backMatter);
|
||||
const rootDocuments = documents(scan.looseScenes) as Scene[];
|
||||
const rootDocuments = documents(scan.looseScenes);
|
||||
const parts = settings.useParts ? scan.parts.map((part) => this.createPart(part, documents, settings)) : [this.createPartlessBook(scan, rootDocuments, documents, settings)];
|
||||
const orphanScenes = settings.useParts ? rootDocuments : [];
|
||||
sortDocuments(frontDocuments, settings.metadataOrdering);
|
||||
|
|
@ -101,7 +101,7 @@ export class ManuscriptParser {
|
|||
}
|
||||
|
||||
private createPart(scanned: ScannedPart, documents: (files: TFile[]) => ManuscriptDocument[], settings: CompileOptions): Part {
|
||||
const direct = documents(scanned.looseScenes) as Scene[];
|
||||
const direct = documents(scanned.looseScenes);
|
||||
const noteChapters = settings.chapterSource === "notes" ? direct.map((scene) => this.chapterFromDocument(scene, settings)) : [];
|
||||
const orphanScenes = settings.chapterSource === "folders" ? direct : [];
|
||||
const chapters = [...noteChapters, ...scanned.chapters.map((chapter) => this.createChapter(chapter, documents, settings))];
|
||||
|
|
@ -111,12 +111,12 @@ export class ManuscriptParser {
|
|||
}
|
||||
private createPartlessBook(scan: ScannedBook, rootDocuments: Scene[], documents: (files: TFile[]) => ManuscriptDocument[], settings: CompileOptions): Part {
|
||||
const rootChapters = settings.chapterSource === "notes" ? rootDocuments.map((scene) => this.chapterFromDocument(scene, settings)) : [];
|
||||
const folderChapters = scan.parts.map((folder) => { const scenes = [...documents(folder.looseScenes), ...folder.chapters.flatMap((chapter) => documents(chapter.scenes))] as Scene[]; const representative = scenes.find((scene) => scene.metadata.chapter !== undefined); const metadataNumber = extractNumber(representative?.metadata.chapter); return { title: folder.folder.name, name: titleName(folder.folder.name), number: settings.metadataOrdering ? metadataNumber ?? extractNumber(folder.folder.name) : extractNumber(folder.folder.name), path: folder.folder.path, order: metadataNumber, scenes, orphan: false }; });
|
||||
const folderChapters = scan.parts.map((folder) => { const scenes = [...documents(folder.looseScenes), ...folder.chapters.flatMap((chapter) => documents(chapter.scenes))]; const representative = scenes.find((scene) => scene.metadata.chapter !== undefined); const metadataNumber = extractNumber(representative?.metadata.chapter); return { title: folder.folder.name, name: titleName(folder.folder.name), number: settings.metadataOrdering ? metadataNumber ?? extractNumber(folder.folder.name) : extractNumber(folder.folder.name), path: folder.folder.path, order: metadataNumber, scenes, orphan: false }; });
|
||||
return { title: scan.root.name, name: scan.root.name, path: scan.root.path, chapters: [...rootChapters, ...folderChapters], orphanScenes: settings.chapterSource === "folders" ? rootDocuments : [], synthetic: true };
|
||||
}
|
||||
private chapterFromDocument(scene: Scene, settings: CompileOptions): Chapter { const metadataNumber = extractNumber(scene.metadata.chapter); return { title: scene.title, name: titleName(scene.title), number: settings.metadataOrdering ? metadataNumber ?? scene.number : scene.number, path: scene.file.path, order: metadataNumber ?? scene.metadata.order, scenes: [scene], orphan: false }; }
|
||||
private createChapter(scanned: ScannedChapter, documents: (files: TFile[]) => ManuscriptDocument[], settings: CompileOptions): Chapter {
|
||||
const scenes = documents(scanned.scenes) as Scene[];
|
||||
const scenes = documents(scanned.scenes);
|
||||
const representative = scenes.find((scene) => scene.metadata.chapter !== undefined);
|
||||
const metadataNumber = extractNumber(representative?.metadata.chapter);
|
||||
return { title: scanned.folder.name, name: titleName(scanned.folder.name), number: settings.metadataOrdering ? metadataNumber ?? extractNumber(scanned.folder.name) : extractNumber(scanned.folder.name), path: scanned.folder.path, order: metadataNumber, scenes, orphan: false };
|
||||
|
|
|
|||
|
|
@ -12,14 +12,14 @@ import { repairCompileLogs, repairExportHistory } from "./history-storage";
|
|||
|
||||
const VELLUM_OPTIONS = { ...DEFAULT_OPTIONS, orderingMethod: "metadata" as const, metadataOrdering: true, partHeadingTemplate: "Part {number}: {name}", chapterHeadingTemplate: "Chapter {number}: {name}", removeHtmlComments: true, removeDataviewBlocks: true, removeCallouts: true, stripInternalLinks: true };
|
||||
export function profileId(): string { return `profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; }
|
||||
function profile(name: string, options = DEFAULT_OPTIONS, firstLineIndentCm = 1.27): CompileProfile { return { ...options, metadataFilters: options.metadataFilters.map((rule) => ({ ...rule })), id: profileId(), name, manuscriptRoot: "", exportFolder: "", outputFilename: "{BookTitle}.docx", variables: { BookTitle: "", Series: "", Author: "" }, exportTarget: "docx", referenceDocx: "", pandocMetadataFile: "", additionalPandocArguments: "", generateTableOfContents: false, keepIntermediateMarkdown: false, docxFirstLineIndentCm: firstLineIndentCm, docxPageSize: "a4" }; }
|
||||
function profile(name: string, options = DEFAULT_OPTIONS, firstLineIndentCm = 1.27): CompileProfile { return { ...options, metadataFilters: options.metadataFilters.map((rule) => ({ ...rule })), id: profileId(), name, manuscriptRoot: "", exportFolder: "", outputFilename: "{BookTitle}.docx", variables: { BookTitle: "", Series: "", Author: "" }, exportTarget: "docx", referenceDocx: "", pandocMetadataFile: "", additionalPandocArguments: "", generateTableOfContents: false, keepIntermediateMarkdown: false, docxIndentParagraphs: true, docxFirstLineIndentCm: firstLineIndentCm, docxPageSize: "a4" }; }
|
||||
/** Creates fresh profiles; callers may mutate them without sharing nested arrays. */
|
||||
export function createDefaultProfiles(): CompileProfile[] { return [profile("Default"), profile("Vellum", VELLUM_OPTIONS, 0.75)]; }
|
||||
/** Copies mutable nested profile values and assigns a new stable identity. */
|
||||
export function duplicateProfile(source: CompileProfile, name = `${source.name} Copy`): CompileProfile { return { ...source, id: profileId(), name, metadataFilters: source.metadataFilters.map((rule) => ({ ...rule })), variables: { ...source.variables } }; }
|
||||
/** Applies historical schema upgrades once while retaining compatibility data. */
|
||||
export function migrateSettings(settings: ManuscriptCompilerSettings): ManuscriptCompilerSettings {
|
||||
if (settings.profiles.length > 0) { settings.profiles = settings.profiles.map((item) => ({ ...item, exportTarget: item.exportTarget ?? settings.defaultExportFormat ?? "markdown", referenceDocx: item.referenceDocx ?? settings.defaultReferenceDocx ?? "", pandocMetadataFile: item.pandocMetadataFile ?? "", additionalPandocArguments: item.additionalPandocArguments ?? "", generateTableOfContents: item.generateTableOfContents ?? false, keepIntermediateMarkdown: item.keepIntermediateMarkdown ?? settings.keepTemporaryMarkdown ?? false })); return settings; }
|
||||
if (settings.profiles.length > 0) { settings.profiles = settings.profiles.map((item) => ({ ...item, exportTarget: item.exportTarget ?? settings.defaultExportFormat ?? "markdown", referenceDocx: item.referenceDocx ?? settings.defaultReferenceDocx ?? "", pandocMetadataFile: item.pandocMetadataFile ?? "", additionalPandocArguments: item.additionalPandocArguments ?? "", generateTableOfContents: item.generateTableOfContents ?? false, keepIntermediateMarkdown: item.keepIntermediateMarkdown ?? settings.keepTemporaryMarkdown ?? false, docxIndentParagraphs: item.docxIndentParagraphs ?? true })); return settings; }
|
||||
const profiles = createDefaultProfiles(); const active = profiles[settings.defaultCompilePreset === "vellum" ? 1 : 0];
|
||||
Object.assign(active, {
|
||||
manuscriptRoot: settings.defaultManuscriptFolder, exportFolder: settings.defaultExportFolder,
|
||||
|
|
@ -46,9 +46,9 @@ export function repairSettings(settings: ManuscriptCompilerSettings): Manuscript
|
|||
repaired.defaultStructurePreset ??= activeForMigration ? (activeForMigration.useParts ? (activeForMigration.chapterSource === "notes" ? "anthology" : "novel-parts") : activeForMigration.chapterSource === "notes" ? "chapter-notes" : "novel") : "novel-parts";
|
||||
repaired.defaultDocxStyle ??= repaired.defaultCompilePreset === "vellum" || /vellum/i.test(activeForMigration?.name ?? "") ? "vellum" : "standard";
|
||||
if (!(repaired.defaultDocxStyle === "vellum" || repaired.defaultDocxStyle === "standard")) repaired.defaultDocxStyle = "standard";
|
||||
repaired.defaultExportFormat ??= "docx"; repaired.warnBeforeOverwrite ??= true; repaired.openAfterCompile ??= false; repaired.includeTitlePageByDefault ??= false; repaired.includeTableOfContentsByDefault ??= activeForMigration?.generateTableOfContents ?? false; repaired.showAdvancedOptions ??= false;
|
||||
repaired.defaultExportFormat ??= "docx"; repaired.warnBeforeOverwrite ??= true; repaired.openAfterCompile ??= false; repaired.includeTitlePageByDefault ??= false; repaired.includeTableOfContentsByDefault ??= activeForMigration?.generateTableOfContents ?? false; repaired.showAdvancedOptions ??= false; repaired.defaultIndentParagraphs = typeof repaired.defaultIndentParagraphs === "boolean" ? repaired.defaultIndentParagraphs : true;
|
||||
repaired.saveToVaultByDefault = repaired.saveToVaultByDefault === true; repaired.rememberExternalSaveFolder = repaired.rememberExternalSaveFolder === true; repaired.revealAfterCompile = repaired.revealAfterCompile === true; if (typeof repaired.lastExternalSaveFolder !== "string") repaired.lastExternalSaveFolder = "";
|
||||
if (!["docx", "odt", "pdf", "epub", "html", "xml"].includes(repaired.defaultDownloadFormat)) repaired.defaultDownloadFormat = "docx";
|
||||
if (!["docx", "odt", "epub", "html", "markdown", "xml"].includes(repaired.defaultDownloadFormat)) repaired.defaultDownloadFormat = "docx";
|
||||
repaired.defaultDocxPageSize = activeForMigration?.docxPageSize === "letter" || activeForMigration?.docxPageSize === "a4" ? activeForMigration.docxPageSize : repaired.defaultDocxPageSize === "letter" ? "letter" : "a4";
|
||||
const migratedDefaultIndent = activeForMigration?.docxFirstLineIndentCm ?? (typeof activeForMigration?.docxFirstLineIndent === "number" ? inchesToCentimetres(activeForMigration.docxFirstLineIndent) : repaired.defaultDocxStyle === "vellum" ? 0.75 : 1.27);
|
||||
repaired.defaultDocxFirstLineIndentCm = clampCentimetres(migratedDefaultIndent, 0, 3.81, repaired.defaultDocxStyle === "vellum" ? 0.75 : 1.27);
|
||||
|
|
@ -66,7 +66,7 @@ export function repairSettings(settings: ManuscriptCompilerSettings): Manuscript
|
|||
const validation = validateProfile(merged); if (validation.errors.length) warnings.push(`Profile “${item.name || index + 1}” has configuration issues: ${validation.errors.join(" ")}`);
|
||||
if (!merged.id) { merged.id = profileId(); warnings.push(`Profile ${index + 1} was assigned a new identifier.`); }
|
||||
if (!merged.name?.trim()) { merged.name = `Recovered Profile ${index + 1}`; warnings.push(`Profile ${index + 1} was assigned a recovery name.`); }
|
||||
if (!["markdown", "docx", "markdown-docx", "odt", "pdf", "epub", "html", "xml"].includes(merged.exportTarget)) { merged.exportTarget = "docx"; warnings.push(`Profile “${merged.name}” export target was repaired to DOCX.`); }
|
||||
if (!["markdown", "docx", "markdown-docx", "odt", "epub", "html", "xml"].includes(merged.exportTarget)) { merged.exportTarget = "docx"; warnings.push(`Profile “${merged.name}” export target was repaired to DOCX.`); }
|
||||
for (const key of ["includeFrontMatter", "includeBackMatter", "includeSceneTitles", "metadataOrdering", "stripYamlFrontmatter", "removeObsidianComments", "removeHtmlComments", "removeDataviewBlocks", "removeCallouts", "stripInternalLinks", "generateTableOfContents", "keepIntermediateMarkdown", "useParts"] as const) if (typeof merged[key] !== "boolean") { (merged[key] as boolean) = defaults[key]; warnings.push(`Profile “${merged.name}” setting ${key} was repaired.`); }
|
||||
if (merged.chapterSource !== "folders" && merged.chapterSource !== "notes") { merged.chapterSource = "folders"; warnings.push(`Profile “${merged.name}” chapter source was repaired to folders.`); }
|
||||
for (const key of ["manuscriptRoot", "exportFolder", "outputFilename", "partHeadingTemplate", "chapterHeadingTemplate", "sceneSeparator", "referenceDocx", "pandocMetadataFile", "additionalPandocArguments"] as const) if (typeof merged[key] !== "string") { (merged[key] as string) = defaults[key]; warnings.push(`Profile “${merged.name}” setting ${key} was repaired.`); }
|
||||
|
|
@ -79,6 +79,7 @@ export function repairSettings(settings: ManuscriptCompilerSettings): Manuscript
|
|||
for (const key of ["blankLinesBetweenSections", "blankLinesBetweenChapters"] as const) if (!Number.isInteger(merged[key]) || merged[key] < 0) { merged[key] = defaults[key]; warnings.push(`Profile “${merged.name}” setting ${key} was repaired.`); }
|
||||
const profileIndentDefault = /vellum/i.test(item.name ?? "") ? 0.75 : defaults.docxFirstLineIndentCm ?? 1.27;
|
||||
const metricIndent = typeof item.docxFirstLineIndentCm === "number" ? item.docxFirstLineIndentCm : typeof item.docxFirstLineIndent === "number" ? inchesToCentimetres(item.docxFirstLineIndent) : profileIndentDefault;
|
||||
merged.docxIndentParagraphs = typeof item.docxIndentParagraphs === "boolean" ? item.docxIndentParagraphs : true;
|
||||
merged.docxFirstLineIndentCm = clampCentimetres(metricIndent, 0, 3.81, profileIndentDefault);
|
||||
merged.docxPageSize = item.docxPageSize === "letter" || item.docxPageSize === "a4" ? item.docxPageSize : "a4";
|
||||
return merged;
|
||||
|
|
@ -103,9 +104,9 @@ export function validateProfile(value: unknown): { profile?: CompileProfile; err
|
|||
if (item.chapterSource !== undefined && item.chapterSource !== "folders" && item.chapterSource !== "notes") errors.push("chapterSource is invalid.");
|
||||
for (const key of ["blankLinesBetweenSections", "blankLinesBetweenChapters"] as const) if (item[key] !== undefined && (!Number.isInteger(item[key]) || (item[key] ?? -1) < 0)) errors.push(`${key} must be a non-negative integer.`);
|
||||
if (item.orderingMethod !== undefined && item.orderingMethod !== "filename" && item.orderingMethod !== "metadata") errors.push("orderingMethod is invalid.");
|
||||
if (item.exportTarget !== undefined && !["markdown", "docx", "markdown-docx", "odt", "pdf", "epub", "html", "xml"].includes(item.exportTarget)) errors.push("exportTarget is invalid.");
|
||||
if (item.exportTarget !== undefined && !["markdown", "docx", "markdown-docx", "odt", "epub", "html", "xml"].includes(item.exportTarget)) errors.push("exportTarget is invalid.");
|
||||
for (const key of ["referenceDocx", "pandocMetadataFile", "additionalPandocArguments"] as const) if (item[key] !== undefined && typeof item[key] !== "string") errors.push(`${key} must be a string.`);
|
||||
for (const key of ["generateTableOfContents", "keepIntermediateMarkdown"] as const) if (item[key] !== undefined && typeof item[key] !== "boolean") errors.push(`${key} must be boolean.`);
|
||||
for (const key of ["generateTableOfContents", "keepIntermediateMarkdown", "docxIndentParagraphs"] as const) if (item[key] !== undefined && typeof item[key] !== "boolean") errors.push(`${key} must be boolean.`);
|
||||
if (item.variables !== undefined && (typeof item.variables !== "object" || item.variables === null || Array.isArray(item.variables))) errors.push("variables must be an object.");
|
||||
if (!Array.isArray(item.metadataFilters)) errors.push("metadataFilters must be an array.");
|
||||
else item.metadataFilters.forEach((rule, index) => {
|
||||
|
|
@ -115,6 +116,6 @@ export function validateProfile(value: unknown): { profile?: CompileProfile; err
|
|||
});
|
||||
if (errors.length > 0) return { errors };
|
||||
const base = profile(item.name?.trim() ?? "Imported");
|
||||
const imported = { ...base, ...item, id: profileId(), variables: { ...base.variables, ...(item.variables ?? {}) }, metadataFilters: (item.metadataFilters ?? []).map((rule) => ({ ...rule, id: typeof rule.id === "string" ? rule.id : profileId() })) } as CompileProfile;
|
||||
const imported: CompileProfile = { ...base, ...item, id: profileId(), variables: { ...base.variables, ...(item.variables ?? {}) }, metadataFilters: (item.metadataFilters ?? []).map((rule) => ({ ...rule, id: typeof rule.id === "string" ? rule.id : profileId() })) };
|
||||
return { profile: imported, errors };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { CompileProfile } from "./settings";
|
|||
import type { ExportFormattingOptions } from "./export-types";
|
||||
import { structuralLines } from "./docx";
|
||||
|
||||
export interface SemanticInline { text: string; bold?: boolean; italic?: boolean; }
|
||||
export interface SemanticInline { text: string; bold?: boolean; italic?: boolean; href?: string; }
|
||||
export type SemanticBlock =
|
||||
| { kind: "heading"; style: "title" | "author" | "front-matter" | "back-matter" | "part-number" | "part-title" | "chapter-number" | "chapter-title" | "body-heading"; inlines: SemanticInline[]; pageBreakBefore?: boolean; pageBreakAfter?: boolean }
|
||||
| { kind: "paragraph"; inlines: SemanticInline[]; first: boolean }
|
||||
|
|
@ -40,7 +40,7 @@ export function bodyBlocks(markdown: string): SemanticBlock[] {
|
|||
}
|
||||
|
||||
export function inlineMarkdown(value: string): SemanticInline[] {
|
||||
const output: SemanticInline[] = []; const pattern = /(\*\*\*|___)(.+?)\1|(\*\*|__)(.+?)\3|(?<!\*)\*([^*]+?)\*|_([^_]+?)_|\[([^\]]+)\]\([^)]+\)|`([^`]+)`/g; let offset = 0; let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(value))) { if (match.index > offset) output.push({ text: value.slice(offset, match.index) }); if (match[2]) output.push({ text: match[2], bold: true, italic: true }); else if (match[4]) output.push({ text: match[4], bold: true }); else if (match[5] || match[6]) output.push({ text: match[5] ?? match[6], italic: true }); else output.push({ text: match[7] ?? match[8] ?? "" }); offset = match.index + match[0].length; } if (offset < value.length) output.push({ text: value.slice(offset) }); return output.length ? output : [{ text: value }];
|
||||
const output: SemanticInline[] = []; const pattern = /(\*\*\*|___)(.+?)\1|(\*\*|__)(.+?)\3|(?<!\*)\*([^*]+?)\*|_([^_]+?)_|\[([^\]]+)\]\(([^)]+)\)|`([^`]+)`/g; let offset = 0; let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(value))) { if (match.index > offset) output.push({ text: value.slice(offset, match.index) }); if (match[2]) output.push({ text: match[2], bold: true, italic: true }); else if (match[4]) output.push({ text: match[4], bold: true }); else if (match[5] || match[6]) output.push({ text: match[5] ?? match[6], italic: true }); else if (match[7]) output.push({ text: match[7], href: match[8] }); else output.push({ text: match[9] ?? "" }); offset = match.index + match[0].length; } if (offset < value.length) output.push({ text: value.slice(offset) }); return output.length ? output : [{ text: value }];
|
||||
}
|
||||
export function plainText(block: SemanticBlock): string { return "inlines" in block ? block.inlines.map((item) => item.text).join("") : block.kind === "scene-break" ? block.text : ""; }
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
export type OrderingMethod = "filename" | "metadata";
|
||||
export type WarningLevel = "information" | "warning" | "error";
|
||||
export type MetadataOperator = "equals" | "not-equals";
|
||||
export type ExportTarget = "markdown" | "docx" | "markdown-docx" | "odt" | "pdf" | "epub" | "html" | "xml";
|
||||
export type ExportTarget = "markdown" | "docx" | "markdown-docx" | "odt" | "epub" | "html" | "xml";
|
||||
export type ChapterSource = "folders" | "notes";
|
||||
export type StructurePreset = "novel-parts" | "novel" | "chapter-notes" | "short-story" | "anthology" | "custom";
|
||||
export type DocxStylePreset = "vellum" | "standard" | "custom";
|
||||
|
|
@ -37,6 +37,8 @@ export interface CompileProfile extends CompileOptions {
|
|||
contentOrder?: string[]; docxFont?: string; docxFontSize?: number; docxLineSpacing?: number;
|
||||
/** Canonical first-line indentation unit for current settings. */
|
||||
docxFirstLineIndentCm?: number;
|
||||
/** Applies the configured first-line indentation to later body paragraphs. */
|
||||
docxIndentParagraphs?: boolean;
|
||||
/** Legacy pre-metric value in inches. Retained only for migration compatibility. */
|
||||
docxFirstLineIndent?: number;
|
||||
docxPageSize?: "letter" | "a4"; docxChapterPageBreak?: boolean; docxTitlePage?: boolean; downloadAfterExport?: boolean;
|
||||
|
|
@ -57,10 +59,10 @@ export interface ManuscriptCompilerSettings extends CompileOptions {
|
|||
configurationWarnings: string[];
|
||||
onboardingCompleted: boolean;
|
||||
defaultStructurePreset: StructurePreset; defaultDocxStyle: DocxStylePreset; warnBeforeOverwrite: boolean;
|
||||
defaultDocxPageSize: "letter" | "a4"; defaultDocxFirstLineIndentCm: number;
|
||||
defaultDocxPageSize: "letter" | "a4"; defaultIndentParagraphs: boolean; defaultDocxFirstLineIndentCm: number;
|
||||
openAfterCompile: boolean; includeTitlePageByDefault: boolean; includeTableOfContentsByDefault: boolean; showAdvancedOptions: boolean;
|
||||
saveToVaultByDefault: boolean; rememberExternalSaveFolder: boolean; lastExternalSaveFolder: string; revealAfterCompile: boolean;
|
||||
defaultDownloadFormat: "docx" | "odt" | "pdf" | "epub" | "html" | "xml";
|
||||
defaultDownloadFormat: "docx" | "odt" | "epub" | "html" | "markdown" | "xml";
|
||||
/* Stage 1/2 migration fields. */
|
||||
defaultManuscriptFolder: string; defaultExportFolder: string; defaultCompilePreset: "default" | "vellum";
|
||||
}
|
||||
|
|
@ -80,7 +82,7 @@ export const DEFAULT_SETTINGS: ManuscriptCompilerSettings = {
|
|||
keepTemporaryMarkdown: false, enableCompileLogs: true, maximumExportHistoryEntries: 50, exportHistory: [], compileLogs: [], configurationWarnings: [], onboardingCompleted: false,
|
||||
defaultManuscriptFolder: "", defaultExportFolder: "", defaultCompilePreset: "default",
|
||||
defaultStructurePreset: "novel-parts", defaultDocxStyle: "vellum", warnBeforeOverwrite: true, openAfterCompile: false,
|
||||
defaultDocxPageSize: "a4", defaultDocxFirstLineIndentCm: 0.75,
|
||||
defaultDocxPageSize: "a4", defaultIndentParagraphs: true, defaultDocxFirstLineIndentCm: 0.75,
|
||||
includeTitlePageByDefault: false, includeTableOfContentsByDefault: false, showAdvancedOptions: false
|
||||
, saveToVaultByDefault: false, rememberExternalSaveFolder: false, lastExternalSaveFolder: "", revealAfterCompile: false, defaultDownloadFormat: "docx"
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import type { CompileProfile, DocxStylePreset, ExportTarget, StructuralDisplay,
|
|||
import type { ContentPlanItem } from "./content-plan";
|
||||
|
||||
/** Controller-owned mutable formatting; centimetres are the canonical UI unit. */
|
||||
export interface DocxFormatting { font: string; fontSize: number; lineSpacing: number; firstLineIndentCm: number; pageSize: "letter" | "a4"; pageMarginCm?: number; chapterPageBreak: boolean; titlePage: boolean; }
|
||||
export interface DocxFormatting { font: string; fontSize: number; lineSpacing: number; indentParagraphs: boolean; firstLineIndentCm: number; pageSize: "letter" | "a4"; chapterPageBreak: boolean; titlePage: boolean; }
|
||||
|
||||
export const DOCX_FORMATTING_PRESETS: Record<Exclude<DocxStylePreset, "custom">, Readonly<DocxFormatting>> = {
|
||||
vellum: { font: "Garamond", fontSize: 12, lineSpacing: 1.15, firstLineIndentCm: 0.75, pageSize: "a4", chapterPageBreak: true, titlePage: false },
|
||||
standard: { font: "Times New Roman", fontSize: 12, lineSpacing: 2, firstLineIndentCm: 1.27, pageSize: "a4", chapterPageBreak: true, titlePage: false }
|
||||
vellum: { font: "Garamond", fontSize: 12, lineSpacing: 1.15, indentParagraphs: true, firstLineIndentCm: 0.75, pageSize: "a4", chapterPageBreak: true, titlePage: false },
|
||||
standard: { font: "Times New Roman", fontSize: 12, lineSpacing: 2, indentParagraphs: true, firstLineIndentCm: 1.27, pageSize: "a4", chapterPageBreak: true, titlePage: false }
|
||||
};
|
||||
|
||||
/** Returns a fresh deterministic preset; Custom copies the caller's values. */
|
||||
|
|
@ -67,7 +67,7 @@ export function resolveSimpleCompileRequest(request: SimpleCompileRequest, base:
|
|||
variables: { ...base.variables, ...(request.custom?.variables ?? {}) }, metadataFilters: base.metadataFilters.map((rule) => ({ ...rule })), referenceDocx: "", pandocMetadataFile: "", additionalPandocArguments: "",
|
||||
contentOrder: request.contentPlan?.filter((item) => item.included && item.role !== "ignore").map((item) => item.path),
|
||||
docxFont: formatting.font, docxFontSize: formatting.fontSize, docxLineSpacing: formatting.lineSpacing,
|
||||
docxFirstLineIndentCm: formatting.firstLineIndentCm, docxPageSize: formatting.pageSize,
|
||||
docxIndentParagraphs: formatting.indentParagraphs, docxFirstLineIndentCm: formatting.firstLineIndentCm, docxPageSize: formatting.pageSize,
|
||||
docxChapterPageBreak: formatting.chapterPageBreak, docxTitlePage: formatting.titlePage, downloadAfterExport: request.downloadAfterExport, skipLegacyPreview: request.contentPlan !== undefined,
|
||||
partDisplay: request.partDisplay ?? "word-title", chapterDisplay: request.chapterDisplay ?? "word-title", explicitlyIncludedPaths: request.contentPlan?.filter((item) => item.userOverride && item.included).map((item) => item.path), bodySectionAliases: request.custom?.bodySectionAliases ?? base.bodySectionAliases
|
||||
};
|
||||
|
|
|
|||
30
src/ui.ts
30
src/ui.ts
|
|
@ -43,7 +43,7 @@ export class CompilePreviewModal extends Modal {
|
|||
this.renderIssues(details, this.preview.issues);
|
||||
new Setting(this.contentEl).addButton((button) => button.setButtonText("Cancel").onClick(() => { this.resolve(false); this.close(); })).addButton((button) => button.setButtonText(this.preview.canExport ? "Compile" : "Export unavailable").setCta().setDisabled(!this.preview.canExport).onClick(() => { this.resolve(true); this.close(); }));
|
||||
}
|
||||
onClose(): void { this.contentEl.empty(); }
|
||||
onClose(): void { this.resolve(false); this.contentEl.empty(); }
|
||||
private branch(parent: HTMLElement, label: string, root = false, status = "included"): HTMLElement { const details = parent.createEl("details"); details.dataset.search = label.toLowerCase(); details.open = root || this.expanded; const summary = details.createEl("summary"); summary.createSpan({ cls: `manuscript-status manuscript-status-${status}`, text: status === "included" ? "✓" : status === "excluded" ? "−" : "!" }); summary.createSpan({ text: `${label} — ${status}` }); return details.createDiv({ cls: "manuscript-tree-children" }); }
|
||||
private documentBranch(parent: HTMLElement, label: string, documents: ManuscriptDocument[], inspector: HTMLElement): void { const branch = this.branch(parent, label, false, documents.some((item) => item.excluded) ? "warning" : "included"); documents.forEach((document) => this.sceneNode(branch, document, inspector)); }
|
||||
private partBranch(parent: HTMLElement, part: Part, inspector: HTMLElement): void { if (part.synthetic) { part.orphanScenes.forEach((scene) => this.sceneNode(parent, scene, inspector)); part.chapters.forEach((chapter) => this.chapterBranch(parent, chapter, inspector)); return; } const branch = this.branch(parent, `📚 ${part.title}`, false, this.statusForPath(part.path)); part.orphanScenes.forEach((scene) => this.sceneNode(branch, scene, inspector)); part.chapters.forEach((chapter) => this.chapterBranch(branch, chapter, inspector)); }
|
||||
|
|
@ -76,13 +76,13 @@ export class ValidationReportModal extends Modal {
|
|||
/** Displays and optionally saves an already-redacted diagnostics report. */
|
||||
export class DiagnosticsReportModal extends Modal {
|
||||
constructor(app: App, private readonly report: string, private readonly save: () => Promise<string>) { super(app); }
|
||||
onOpen(): void { this.titleEl.setText("Diagnostics report"); this.contentEl.createEl("p", { text: "This report contains configuration and environment information, but no manuscript contents." }); const area = new TextAreaComponent(this.contentEl); area.setValue(this.report); area.inputEl.readOnly = true; area.inputEl.addClass("manuscript-profile-json"); new Setting(this.contentEl).addButton((button) => button.setButtonText("Copy").onClick(async () => { try { await navigator.clipboard.writeText(this.report); new Notice("Diagnostics report copied."); } catch { area.inputEl.focus(); area.inputEl.select(); } })).addButton((button) => button.setButtonText("Save diagnostics note").setCta().onClick(async () => { new Notice(`Diagnostics saved to ${await this.save()}`, 6000); this.close(); })).addButton((button) => button.setButtonText("Close").onClick(() => this.close())); }
|
||||
onOpen(): void { this.titleEl.setText("Diagnostics report"); this.contentEl.createEl("p", { text: "This report contains configuration and environment information, but no manuscript contents." }); const area = new TextAreaComponent(this.contentEl); area.setValue(this.report); area.inputEl.readOnly = true; area.inputEl.addClass("manuscript-profile-json"); new Setting(this.contentEl).addButton((button) => button.setButtonText("Copy").onClick(async () => { try { await area.inputEl.win.navigator.clipboard.writeText(this.report); new Notice("Diagnostics report copied."); } catch { area.inputEl.focus(); area.inputEl.select(); } })).addButton((button) => button.setButtonText("Save diagnostics note").setCta().onClick(async () => { new Notice(`Diagnostics saved to ${await this.save()}`, 6000); this.close(); })).addButton((button) => button.setButtonText("Close").onClick(() => this.close())); }
|
||||
}
|
||||
|
||||
class ProfileJsonModal extends Modal {
|
||||
private value: string;
|
||||
constructor(app: App, private readonly mode: "import" | "export", profile: CompileProfile, private readonly done: (value?: string) => void) { super(app); this.value = mode === "export" ? JSON.stringify(profile, null, 2) : ""; }
|
||||
onOpen(): void { this.titleEl.setText(`${this.mode === "import" ? "Import" : "Export"} compile profile`); const area = new TextAreaComponent(this.contentEl); area.setValue(this.value).onChange((value) => { this.value = value; }); area.inputEl.addClass("manuscript-profile-json"); new Setting(this.contentEl).addButton((button) => button.setButtonText("Close").onClick(() => this.close())); if (this.mode === "import") new Setting(this.contentEl).addButton((button) => button.setButtonText("Import").setCta().onClick(() => { this.done(this.value); this.close(); })); }
|
||||
constructor(app: App, private readonly mode: "import" | "export", profile: CompileProfile, private readonly done: (value?: string) => void | Promise<void>) { super(app); this.value = mode === "export" ? JSON.stringify(profile, null, 2) : ""; }
|
||||
onOpen(): void { this.titleEl.setText(`${this.mode === "import" ? "Import" : "Export"} compile profile`); const area = new TextAreaComponent(this.contentEl); area.setValue(this.value).onChange((value) => { this.value = value; }); area.inputEl.addClass("manuscript-profile-json"); new Setting(this.contentEl).addButton((button) => button.setButtonText("Close").onClick(() => this.close())); if (this.mode === "import") new Setting(this.contentEl).addButton((button) => button.setButtonText("Import").setCta().onClick(() => { void this.done(this.value); this.close(); })); }
|
||||
}
|
||||
class ExportHistoryModal extends Modal { constructor(app: App, private readonly plugin: ManuscriptCompilerPlugin) { super(app); } onOpen(): void { this.titleEl.setText("Export history"); this.plugin.settings.exportHistory.forEach((entry) => { const details = this.contentEl.createEl("details"); details.createEl("summary", { text: `${entry.cancelled ? "—" : entry.success ? "✓" : "✗"} ${new Date(entry.timestamp).toLocaleString()} — ${(entry.format ?? "docx").toUpperCase()}` }); details.createEl("p", { text: `${entry.manuscript} · ${entry.wordCount.toLocaleString()} words` }); entry.outputFiles.forEach((filename) => details.createEl("p", { text: filename })); }); new Setting(this.contentEl).addButton((button) => button.setButtonText("Clear history and logs").setWarning().onClick(async () => { await this.plugin.clearHistory(); this.close(); })).addButton((button) => button.setButtonText("Close").onClick(() => this.close())); } }
|
||||
class CompileLogsModal extends Modal { constructor(app: App, private readonly plugin: ManuscriptCompilerPlugin) { super(app); } onOpen(): void { this.titleEl.setText("Compile logs"); this.plugin.settings.compileLogs.forEach((log) => { const details = this.contentEl.createEl("details"); details.createEl("summary", { text: `${log.cancelled ? "Cancelled" : log.success ? "Success" : "Failure"} — ${new Date(log.timestamp).toLocaleString()} — ${log.profile}` }); const pre = details.createEl("pre"); pre.setText([`Compiler: ${log.compilerVersion}`, `Manuscript: ${log.manuscript}`, `Formats: ${log.exportFormats}`, `Outputs: ${log.outputFiles.join(", ") || "None"}`, `Duration: ${log.durationMs} ms`, `Warnings: ${log.warnings.join(" | ") || "None"}`, log.diagnostics ? `Diagnostics:\n${log.diagnostics}` : ""].filter(Boolean).join("\n")); }); new Setting(this.contentEl).addButton((button) => button.setButtonText("Close").onClick(() => this.close())); } }
|
||||
|
|
@ -90,28 +90,30 @@ class CompileLogsModal extends Modal { constructor(app: App, private readonly pl
|
|||
/** Defaults/advanced compatibility settings; not the primary compile workspace. */
|
||||
export class ManuscriptCompilerSettingTab extends PluginSettingTab {
|
||||
constructor(app: App, private readonly plugin: ManuscriptCompilerPlugin) { super(app, plugin); }
|
||||
display(): void {
|
||||
const container = this.containerEl; const settings = this.plugin.settings; const profile = this.plugin.getActiveProfile(); container.empty(); container.createEl("h2", { text: "Manuscript Compiler" });
|
||||
new Setting(container).addButton((button) => button.setButtonText("Open Manuscript Compiler").setCta().onClick(() => this.plugin.openCompiler()));
|
||||
container.createEl("h3", { text: "Defaults" }); this.text(container, "Default manuscript folder", settings.defaultManuscriptFolder, (value) => { settings.defaultManuscriptFolder = value; });
|
||||
new Setting(container).setName("Default structure").addDropdown((dropdown) => { Object.entries(STRUCTURE_PRESET_NAMES).forEach(([value, label]) => dropdown.addOption(value, label)); dropdown.setValue(settings.defaultStructurePreset).onChange(async (value) => { settings.defaultStructurePreset = value as StructurePreset; await this.plugin.saveSettings(); }); });
|
||||
new Setting(container).setName("Default format").addDropdown((dropdown) => { EXPORT_FORMATS.forEach((format) => dropdown.addOption(format, EXPORT_FORMAT_DETAILS[format].label)); dropdown.setValue(settings.defaultDownloadFormat).onChange(async (value) => { settings.defaultDownloadFormat = value as ExportFormat; await this.plugin.saveSettings(); }); });
|
||||
new Setting(container).setName("Default document style").addDropdown((dropdown) => dropdown.addOption("vellum", "Vellum-ready").addOption("standard", "Standard manuscript").setValue(settings.defaultDocxStyle === "custom" ? "standard" : settings.defaultDocxStyle).onChange(async (value) => { settings.defaultDocxStyle = value as DocxStylePreset; await this.plugin.saveSettings(); }));
|
||||
display(): void { this.renderSettings(); }
|
||||
private renderSettings(): void {
|
||||
const container = this.containerEl; const settings = this.plugin.settings; const profile = this.plugin.getActiveProfile(); container.empty();
|
||||
new Setting(container).setName("Compiler").addButton((button) => button.setButtonText("Open compiler").setCta().onClick(() => this.plugin.openCompiler()));
|
||||
new Setting(container).setName("Defaults").setHeading(); this.text(container, "Default manuscript folder", settings.defaultManuscriptFolder, (value) => { settings.defaultManuscriptFolder = value; });
|
||||
new Setting(container).setName("Default structure").addDropdown((dropdown) => { Object.entries(STRUCTURE_PRESET_NAMES).forEach(([value, label]) => { dropdown.addOption(value, label); }); dropdown.setValue(settings.defaultStructurePreset).onChange((value) => { settings.defaultStructurePreset = value as StructurePreset; void this.plugin.saveSettings(); }); });
|
||||
new Setting(container).setName("Default format").addDropdown((dropdown) => { EXPORT_FORMATS.forEach((format) => { dropdown.addOption(format, EXPORT_FORMAT_DETAILS[format].label); }); dropdown.setValue(settings.defaultDownloadFormat).onChange((value) => { settings.defaultDownloadFormat = value as ExportFormat; void this.plugin.saveSettings(); }); });
|
||||
new Setting(container).setName("Default document style").addDropdown((dropdown) => dropdown.addOption("vellum", "Vellum-ready").addOption("standard", "Standard manuscript").setValue(settings.defaultDocxStyle === "custom" ? "standard" : settings.defaultDocxStyle).onChange((value) => { settings.defaultDocxStyle = value as DocxStylePreset; void this.plugin.saveSettings(); }));
|
||||
this.toggle(container, "Include title page", settings.includeTitlePageByDefault, (value) => { settings.includeTitlePageByDefault = value; }); this.toggle(container, "Include table of contents", settings.includeTableOfContentsByDefault, (value) => { settings.includeTableOfContentsByDefault = value; });
|
||||
const advanced = container.createEl("details"); advanced.createEl("summary", { text: "Advanced profiles, records, and compatibility" });
|
||||
new Setting(advanced).setName("Advanced profile").setDesc("Used for customised or older workflows.").addDropdown((dropdown) => { settings.profiles.forEach((item) => dropdown.addOption(item.id, item.name)); dropdown.setValue(profile.id).onChange(async (id) => { settings.activeProfileId = id; await this.plugin.saveSettings(); this.display(); }); });
|
||||
new Setting(advanced).setName("Profile actions").addButton((button) => button.setButtonText("New").onClick(() => new ProfileWizardModal(this.app, this.plugin, async (created) => { settings.profiles.push(created); settings.activeProfileId = created.id; await this.plugin.saveSettings(); this.display(); }).open())).addButton((button) => button.setButtonText("Duplicate").onClick(async () => { const copy = duplicateProfile(profile); settings.profiles.push(copy); settings.activeProfileId = copy.id; await this.plugin.saveSettings(); this.display(); })).addButton((button) => button.setButtonText("Delete").setWarning().setDisabled(settings.profiles.length < 2).onClick(async () => { settings.profiles = settings.profiles.filter((item) => item.id !== profile.id); settings.activeProfileId = settings.profiles[0].id; await this.plugin.saveSettings(); this.display(); }));
|
||||
new Setting(advanced).setName("Advanced profile").setDesc("Used for customised or older workflows.").addDropdown((dropdown) => { settings.profiles.forEach((item) => { dropdown.addOption(item.id, item.name); }); dropdown.setValue(profile.id).onChange((id) => { settings.activeProfileId = id; void this.saveAndRender(); }); });
|
||||
new Setting(advanced).setName("Profile actions").addButton((button) => button.setButtonText("New").onClick(() => new ProfileWizardModal(this.app, this.plugin, async (created) => { settings.profiles.push(created); settings.activeProfileId = created.id; await this.saveAndRender(); }).open())).addButton((button) => button.setButtonText("Duplicate").onClick(() => { const copy = duplicateProfile(profile); settings.profiles.push(copy); settings.activeProfileId = copy.id; void this.saveAndRender(); })).addButton((button) => button.setButtonText("Delete").setWarning().setDisabled(settings.profiles.length < 2).onClick(() => { settings.profiles = settings.profiles.filter((item) => item.id !== profile.id); settings.activeProfileId = settings.profiles[0].id; void this.saveAndRender(); }));
|
||||
new Setting(advanced).setName("Profile JSON").addButton((button) => button.setButtonText("Export").onClick(() => new ProfileJsonModal(this.app, "export", profile, () => undefined).open())).addButton((button) => button.setButtonText("Import").onClick(() => new ProfileJsonModal(this.app, "import", profile, async (json) => {
|
||||
if (!json) return;
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(json); } catch { new Notice("Profile JSON is invalid. Correct the JSON syntax and try again.", 8000); return; }
|
||||
const validation = validateProfile(parsed); if (!validation.profile) { new Notice(validation.errors.join(" "), 8000); return; }
|
||||
settings.profiles.push(validation.profile); settings.activeProfileId = validation.profile.id; await this.plugin.saveSettings(); this.display();
|
||||
settings.profiles.push(validation.profile); settings.activeProfileId = validation.profile.id; await this.saveAndRender();
|
||||
}).open()));
|
||||
new Setting(advanced).setName("Export records").addButton((button) => button.setButtonText(`History (${settings.exportHistory.length})`).onClick(() => new ExportHistoryModal(this.app, this.plugin).open())).addButton((button) => button.setButtonText(`Logs (${settings.compileLogs.length})`).onClick(() => new CompileLogsModal(this.app, this.plugin).open()));
|
||||
}
|
||||
private text(parent: HTMLElement, name: string, value: string, change: (value: string) => void): void { new Setting(parent).setName(name).addText((text) => text.setValue(value).onChange(async (next) => { change(next.trim()); await this.plugin.saveSettings(); })); }
|
||||
private toggle(parent: HTMLElement, name: string, value: boolean, change: (value: boolean) => void): void { new Setting(parent).setName(name).addToggle((toggle) => toggle.setValue(value).onChange(async (next) => { change(next); await this.plugin.saveSettings(); })); }
|
||||
private async saveAndRender(): Promise<void> { await this.plugin.saveSettings(); this.renderSettings(); }
|
||||
}
|
||||
|
||||
function row(list: HTMLElement, label: string, value: string): void { list.createEl("dt", { text: label }); list.createEl("dd", { text: value }); }
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function profileFromWizard(choices: WizardChoices): CompileProfile {
|
|||
export class ProfileWizardModal extends Modal {
|
||||
protected choices = initialChoices();
|
||||
constructor(app: App, protected readonly plugin: ManuscriptCompilerPlugin, private readonly finished?: (profile: CompileProfile) => Promise<void>) { super(app); }
|
||||
onOpen(): void { this.modalEl.addClass("manuscript-wizard"); this.titleEl.setText("New compile profile"); this.contentEl.createEl("p", { text: "Answer a few questions. Every generated option remains editable in Settings." }); this.renderQuestions(); new Setting(this.contentEl).addButton((button) => button.setButtonText("Cancel").onClick(() => this.close())).addButton((button) => button.setButtonText("Create profile").setCta().onClick(async () => { const profile = profileFromWizard(this.choices); if (this.finished) await this.finished(profile); else { this.plugin.settings.profiles.push(profile); this.plugin.settings.activeProfileId = profile.id; await this.plugin.saveSettings(); } new Notice(`Profile “${profile.name}” created.`); this.close(); })); }
|
||||
onOpen(): void { this.modalEl.addClass("manuscript-wizard"); this.titleEl.setText("New compile profile"); this.contentEl.createEl("p", { text: "Answer a few questions. Every generated option remains editable in settings." }); this.renderQuestions(); new Setting(this.contentEl).addButton((button) => button.setButtonText("Cancel").onClick(() => this.close())).addButton((button) => button.setButtonText("Create profile").setCta().onClick(async () => { const profile = profileFromWizard(this.choices); if (this.finished) await this.finished(profile); else { this.plugin.settings.profiles.push(profile); this.plugin.settings.activeProfileId = profile.id; await this.plugin.saveSettings(); } new Notice(`Profile “${profile.name}” created.`); this.close(); })); }
|
||||
protected renderQuestions(): void {
|
||||
new Setting(this.contentEl).setName("Profile name").addText((text) => { text.setValue(this.choices.name).onChange((value) => { this.choices.name = value; }); text.inputEl.setAttribute("aria-label", "Compile profile name"); });
|
||||
new Setting(this.contentEl).setName("Are chapters folders or notes?").addDropdown((dropdown) => dropdown.addOption("folders", "Folders containing scenes").addOption("notes", "Individual chapter notes").setValue(this.choices.chapterSource).onChange((value) => { this.choices.chapterSource = value === "notes" ? "notes" : "folders"; }));
|
||||
|
|
@ -51,8 +51,8 @@ export class FirstRunWizardModal extends Modal {
|
|||
new Setting(this.contentEl).setName("Manuscript folder").setDesc(this.choices.manuscriptRoot || "Not selected").addButton((button) => button.setButtonText("Choose").setCta().onClick(() => new WizardFolderPicker(this.app, (folder) => { this.choices.manuscriptRoot = folder.path; this.render(); }).open()));
|
||||
new Setting(this.contentEl).setName("Profile style").addDropdown((dropdown) => dropdown.addOption("standard", "Standard").addOption("vellum", "Vellum").setValue(this.choices.vellum ? "vellum" : "standard").onChange((value) => { this.choices.vellum = value === "vellum"; }));
|
||||
new Setting(this.contentEl).setName("Book structure").addDropdown((dropdown) => { for (const [value, label] of Object.entries(STRUCTURE_PRESET_NAMES)) if (value !== "custom") dropdown.addOption(value, label); dropdown.setValue(this.choices.structurePreset ?? "novel-parts").onChange((value) => { this.choices.structurePreset = value as StructurePreset; this.choices.useParts = value === "novel-parts" || value === "anthology"; this.choices.chapterSource = value === "chapter-notes" || value === "short-story" || value === "anthology" ? "notes" : "folders"; }); });
|
||||
new Setting(this.contentEl).setName("File downloads").setDesc("DOCX, ODT, PDF, EPUB, HTML, and XML are generated locally without external converters.");
|
||||
new Setting(this.contentEl).setName("Compile sample after setup").setDesc("Available when samples/Complete Sample Book exists in this vault.").addToggle((toggle) => toggle.setValue(this.compileSample).onChange((value) => { this.compileSample = value; }));
|
||||
new Setting(this.contentEl).setName("File downloads").setDesc("DOCX, ODT, EPUB, HTML, Markdown, and XML are generated locally without external converters.");
|
||||
new Setting(this.contentEl).setName("Compile sample after setup").setDesc("Available when the bundled sample book exists in this vault.").addToggle((toggle) => toggle.setValue(this.compileSample).onChange((value) => { this.compileSample = value; }));
|
||||
new Setting(this.contentEl).addButton((button) => button.setButtonText("Skip for now").onClick(async () => { this.plugin.settings.onboardingCompleted = true; await this.plugin.saveSettings(); this.close(); })).addButton((button) => button.setButtonText("Finish setup").setCta().onClick(async () => { const profile = profileFromWizard({ ...this.choices, name: this.choices.vellum ? "Vellum" : "Standard" }); this.plugin.settings.profiles.push(profile); this.plugin.settings.activeProfileId = profile.id; this.plugin.settings.defaultProfileId = profile.id; this.plugin.settings.defaultManuscriptFolder = this.choices.manuscriptRoot; this.plugin.settings.defaultStructurePreset = this.choices.structurePreset ?? "novel-parts"; this.plugin.settings.defaultDocxStyle = this.choices.vellum ? "vellum" : "standard"; this.plugin.settings.defaultDownloadFormat = "docx"; this.plugin.settings.onboardingCompleted = true; await this.plugin.saveSettings(); this.close(); if (this.compileSample) await this.plugin.compileSampleManuscript(); }));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,5 +195,5 @@ export class CompileWorkspaceController {
|
|||
}
|
||||
|
||||
function workspaceError(message: string, detail?: unknown): WorkspaceError {
|
||||
return { message, suggestion: "Review the manuscript choices and refresh the preview.", technicalDetail: detail instanceof Error ? detail.message : detail === undefined ? undefined : String(detail), severity: "error", recoverable: true };
|
||||
return { message, suggestion: "Review the manuscript choices and refresh the preview.", technicalDetail: detail instanceof Error ? detail.message : typeof detail === "string" ? detail : undefined, severity: "error", recoverable: true };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/** Compact Contents review with an explicit full-control correction mode. */
|
||||
import { Setting } from "obsidian";
|
||||
import { setIcon, Setting } from "obsidian";
|
||||
import type { ContentPlanItem, ContentRole } from "../content-plan";
|
||||
import type { CompileWorkspaceController } from "./compile-workspace-controller";
|
||||
import { orderedPlan, visibleRows } from "./content-tree";
|
||||
|
|
@ -18,10 +18,13 @@ export function renderContentsStep(container: HTMLElement, controller: CompileWo
|
|||
container.createEl("p", { text: "Review the detected structure. Correct anything that is not right." });
|
||||
const summary = container.createDiv({ cls: "manuscript-contents-summary", attr: { "aria-label": "Manuscript contents summary" } });
|
||||
[[`${counts.includedNotes} of ${counts.totalNotes}`, "notes included"], [String(counts.parts), "Parts"], [String(counts.chapters), "Chapters"], [String(counts.scenes), "Scenes"], [String(counts.frontMatter), "Front Matter"], [String(counts.backMatter), "Back Matter"], [String(counts.ignoredNotes), "Ignored"]].forEach(([value, label]) => { const item = summary.createDiv(); item.createEl("strong", { text: value }); item.createSpan({ text: label }); });
|
||||
const toolbar = container.createDiv({ cls: "manuscript-contents-toolbar" });
|
||||
const correction = toolbar.createEl("button", { text: viewState.correctionMode ? "Finish correcting" : "Correct structure", cls: viewState.correctionMode ? "mod-cta" : "" });
|
||||
const toolbar = container.createDiv({ cls: `manuscript-contents-toolbar${viewState.correctionMode ? " is-active" : ""}` });
|
||||
const label = viewState.correctionMode ? "Finish correcting structure" : "Correct structure";
|
||||
const correction = toolbar.createEl("button", { cls: `mod-cta manuscript-correct-structure${viewState.correctionMode ? " is-active" : ""}`, attr: { type: "button", "aria-label": label } });
|
||||
const icon = correction.createSpan({ cls: "manuscript-correct-structure-icon", attr: { "aria-hidden": "true" } }); setIcon(icon, "list-tree"); correction.createSpan({ text: label });
|
||||
correction.setAttribute("aria-pressed", String(viewState.correctionMode));
|
||||
correction.addEventListener("click", () => { viewState.setCorrectionMode(!viewState.correctionMode); renderAgain(container, controller, viewState, undefined, true); });
|
||||
toolbar.createEl("p", { text: "Change folder and note types, inclusion, and order." });
|
||||
if (viewState.correctionMode) renderCorrectionMode(container, controller, viewState);
|
||||
else renderReviewMode(container, controller, viewState, counts.ignoredNotes, counts.warnings, counts.ambiguous);
|
||||
}
|
||||
|
|
@ -63,7 +66,7 @@ function renderOutlineChildren(parent: HTMLElement, parentPath: string, controll
|
|||
(childrenByParent.get(parentPath) ?? []).forEach((item) => {
|
||||
if (!includedPaths.has(item.path) || item.role === "ignore") return;
|
||||
const hasChildren = item.kind === "folder" && item.role !== "chapter" && (childrenByParent.get(item.path) ?? []).some((candidate) => candidate.role !== "ignore");
|
||||
const row = parent.createDiv({ cls: `manuscript-outline-row manuscript-outline-${item.role}${item.role === "transparent" ? " is-transparent" : ""}`, attr: { role: "treeitem" } }); row.style.setProperty("--manuscript-depth", String(depth));
|
||||
const row = parent.createDiv({ cls: `manuscript-outline-row manuscript-outline-${item.role}${item.role === "transparent" ? " is-transparent" : ""}`, attr: { role: "treeitem" } }); row.setCssProps({ "--manuscript-depth": String(depth) });
|
||||
if (hasChildren) {
|
||||
createFolderToggle(row, item, viewState.isExpanded(item.path), () => { viewState.toggle(item.path); viewState.setFocus(item.path, "toggle"); renderAgain(parent.closest(".manuscript-compile-body") as HTMLElement, controller, viewState, item.path); });
|
||||
} else row.createSpan({ text: "•", cls: "manuscript-outline-marker", attr: { "aria-hidden": "true" } });
|
||||
|
|
@ -98,11 +101,11 @@ function renderCorrectionMode(container: HTMLElement, controller: CompileWorkspa
|
|||
};
|
||||
const syncOrderAndVisibility = (): void => { const items = byPath(); visibleRows(controller.state.contentPlan, request.manuscriptRoot).forEach(({ item }) => { const record = records.get(item.path); if (!record) return; record.element.hidden = !viewState.isVisible(item, items); tree.appendChild(record.element); }); };
|
||||
const refreshChangedRows = (before: Map<string, RowSnapshot>): void => { const after = snapshots(); const items = byPath(); changedRowPaths(before, after).forEach((path) => updateRow(path, after, items)); updateSummary(); };
|
||||
const preserveInteraction = (element: HTMLElement, change: () => void): void => { const scrollTop = container.scrollTop; change(); container.scrollTop = scrollTop; viewState.setScrollTop(scrollTop); if (document.activeElement !== element) element.focus({ preventScroll: true }); };
|
||||
const preserveInteraction = (element: HTMLElement, change: () => void): void => { const scrollTop = container.scrollTop; change(); container.scrollTop = scrollTop; viewState.setScrollTop(scrollTop); if (element.doc.activeElement !== element) element.focus({ preventScroll: true }); };
|
||||
summary.addButton((button) => button.setButtonText("Include all").onClick(() => { const before = snapshots(); controller.includeAll(); refreshChangedRows(before); })).addButton((button) => button.setButtonText("Exclude all").onClick(() => { const before = snapshots(); controller.excludeAllNotes(); refreshChangedRows(before); }));
|
||||
const initialItems = byPath();
|
||||
visibleRows(plan, request.manuscriptRoot).filter(({ item }) => viewState.isVisible(item, initialItems)).forEach(({ item, depth, included }) => {
|
||||
const row = tree.createDiv({ cls: `manuscript-content-row ${included ? "" : "is-excluded"}`, attr: { role: "treeitem" } }); row.style.setProperty("--manuscript-depth", String(depth)); row.dataset.path = item.path;
|
||||
const row = tree.createDiv({ cls: `manuscript-content-row ${included ? "" : "is-excluded"}`, attr: { role: "treeitem" } }); row.setCssProps({ "--manuscript-depth": String(depth) }); row.dataset.path = item.path;
|
||||
const include = row.createEl("input", { type: "checkbox" }); include.checked = included; include.setAttribute("aria-label", `${included ? "Exclude" : "Include"} ${item.name}`); rememberFocus(item.path, "include", include);
|
||||
const label = row.createDiv({ cls: "manuscript-content-name" }); let toggle: HTMLButtonElement | undefined;
|
||||
if (item.kind === "folder") { toggle = createFolderToggle(label, item, viewState.isExpanded(item.path)); rememberFocus(item.path, "toggle", toggle); } else label.createSpan({ text: "•", cls: "manuscript-content-icon" });
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import { buildExportPreviewViewModel } from "./export-preview";
|
|||
import { attentionWarnings, informationMessages, manuscriptPlanSummary, warningCategories } from "./workspace-view-model";
|
||||
|
||||
const sceneBreakValues = new Set(["#", "*", "***", "* * *", ""]);
|
||||
const formatDescriptions: Record<ExportFormat, string> = { docx: "Microsoft Word document", odt: "OpenDocument Text", pdf: "Fixed-layout document", epub: "Ebook", html: "Standalone webpage", xml: "Structured manuscript" };
|
||||
const paragraphIndentFormats = new Set<ExportFormat>(["docx", "odt", "epub", "html"]);
|
||||
const formatDescriptions: Record<ExportFormat, string> = { docx: "Microsoft Word document", odt: "OpenDocument Text", epub: "Ebook", html: "Standalone webpage", markdown: "Portable plain-text manuscript", xml: "Structured manuscript" };
|
||||
export interface CreateDocxStepActions { refresh(): void; changed(): void; rerender(): void; }
|
||||
|
||||
export function renderCreateDocxStep(container: HTMLElement, controller: CompileWorkspaceController, actions: CreateDocxStepActions): void {
|
||||
|
|
@ -16,11 +17,11 @@ export function renderCreateDocxStep(container: HTMLElement, controller: Compile
|
|||
container.createEl("h2", { text: "Create file" }); container.createEl("p", { cls: "manuscript-compact-note", text: "Your Markdown notes will not be changed." });
|
||||
container.createEl("h3", { text: "Book summary" }); container.createEl("strong", { text: title, cls: "manuscript-resolved-title" }); container.createEl("p", { cls: "manuscript-compact-summary", text: prepared ? `${prepared.statistics.totalWordCount.toLocaleString()} words · ${prepared.statistics.chapterCount} chapters · ${counts.includedNotes} included notes` : `${counts.includedNotes} included notes · ${counts.chapters} chapters` });
|
||||
if (state.preparationStatus === "preparing") container.createEl("p", { cls: "manuscript-preparation-status", text: "Preparing final manuscript…", attr: { role: "status", "aria-live": "polite" } });
|
||||
else if (!prepared) { const status = container.createDiv({ cls: "manuscript-ready-card" }); status.createEl("strong", { text: state.error ? "Preview needs attention" : "Final manuscript needs preparation" }); status.createEl("p", { text: state.error?.message || "Create will prepare and verify the selected manuscript." }); if (state.error?.suggestion) status.createEl("p", { text: state.error.suggestion }); status.createEl("button", { text: "Refresh Preview" }).addEventListener("click", actions.refresh); }
|
||||
else if (!prepared) { const status = container.createDiv({ cls: "manuscript-ready-card" }); status.createEl("strong", { text: state.error ? "Preview needs attention" : "Final manuscript needs preparation" }); status.createEl("p", { text: state.error?.message || "Create will prepare and verify the selected manuscript." }); if (state.error?.suggestion) status.createEl("p", { text: state.error.suggestion }); status.createEl("button", { text: "Refresh preview" }).addEventListener("click", () => actions.refresh()); }
|
||||
|
||||
renderFormatSelector(container, controller, actions, title);
|
||||
|
||||
if (format !== "xml") renderFormatting(container, controller, actions, format);
|
||||
renderFormatting(container, controller, actions, format);
|
||||
renderAdvancedFormatting(container, controller, actions, format);
|
||||
renderWarnings(container, prepared?.warnings ?? [], counts.ignoredNotes);
|
||||
const filename = exportFilename(request.outputFilename, format, title); container.createEl("h3", { text: "Output filename" }); new Setting(container).setName("Filename").setDesc("The selected format extension is corrected when the file is created.").addText((text) => text.setValue(filename).onChange((value) => controller.setDownloadFilename(value)));
|
||||
|
|
@ -65,25 +66,34 @@ export function formatAfterKey(current: ExportFormat, key: string): ExportFormat
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export function supportsParagraphIndentation(format: ExportFormat): boolean { return paragraphIndentFormats.has(format); }
|
||||
|
||||
function renderFormatting(container: HTMLElement, controller: CompileWorkspaceController, actions: CreateDocxStepActions, format: ExportFormat): void {
|
||||
const { request, formatting } = controller.state; container.createEl("h3", { text: "Formatting" });
|
||||
if (format === "epub" || format === "html") new Setting(container).setName(format === "epub" ? "Reflowable ebook" : "Web-readable").setDesc("Typography and spacing can be adjusted under Advanced formatting.");
|
||||
else new Setting(container).setName(format === "pdf" ? "Book-style structure" : "Document style").addDropdown((dropdown) => dropdown.addOption("vellum", format === "pdf" ? "Book-style structure" : "Vellum").addOption("standard", "Standard Manuscript").addOption("custom", "Custom").setValue(request.docxPreset).onChange((value) => invalidate(controller, actions, () => controller.setDocxPreset(value as DocxStylePreset))));
|
||||
if (format === "xml") { new Setting(container).setName("Paragraph indentation").setDesc("Paragraph indentation is controlled by the application that consumes the XML."); return; }
|
||||
if (format === "markdown") { new Setting(container).setName("Plain-text structure").setDesc("Parts, chapters, scenes, emphasis, and links are preserved as Markdown."); new Setting(container).setName("Paragraph indentation").setDesc("Markdown does not support portable first-line indentation."); }
|
||||
else if (format === "epub" || format === "html") new Setting(container).setName(format === "epub" ? "Reflowable ebook" : "Web-readable").setDesc("Typography and spacing can be adjusted under advanced formatting.");
|
||||
else new Setting(container).setName("Document style").addDropdown((dropdown) => dropdown.addOption("vellum", "Vellum").addOption("standard", "Standard manuscript").addOption("custom", "Custom").setValue(request.docxPreset).onChange((value) => invalidate(controller, actions, () => controller.setDocxPreset(value as DocxStylePreset))));
|
||||
if (supportsParagraphIndentation(format)) {
|
||||
let indentSize: Setting | undefined;
|
||||
new Setting(container).setName("Indent first line of paragraphs").setDesc("Indent only the first line of later body paragraphs; first paragraphs after headings and scene breaks stay flush left.").addToggle((toggle) => toggle.setValue(formatting.indentParagraphs).onChange((value) => { invalidate(controller, actions, () => controller.setFormatting({ indentParagraphs: value })); if (indentSize) indentSize.settingEl.hidden = !value; }));
|
||||
indentSize = new Setting(container).setName("First-line indent (cm)").addDropdown((dropdown) => dropdown.addOption("0", "None").addOption("0.75", "0.75 cm").addOption("1.27", "1.27 cm").setValue(String(formatting.firstLineIndentCm)).onChange((value) => invalidate(controller, actions, () => controller.setFormatting({ firstLineIndentCm: Number(value) }))));
|
||||
indentSize.settingEl.hidden = !formatting.indentParagraphs;
|
||||
}
|
||||
new Setting(container).setName("Scene break").addDropdown((dropdown) => { const separator = effectiveSeparator(controller); return dropdown.addOption("#", "#").addOption("*", "*").addOption("***", "***").addOption("* * *", "* * *").addOption("", "Blank line").addOption("custom", "Custom").setValue(sceneBreakValues.has(separator) ? separator : "custom").onChange((value) => { if (value !== "custom") invalidate(controller, actions, () => controller.setSceneSeparator(value)); }); });
|
||||
new Setting(container).setName("Add title page").addToggle((toggle) => toggle.setValue(formatting.titlePage).onChange((value) => invalidate(controller, actions, () => controller.setFormatting({ titlePage: value }))));
|
||||
if (format !== "pdf") new Setting(container).setName("Add table of contents").addToggle((toggle) => toggle.setValue(request.tableOfContents === true).onChange((value) => invalidate(controller, actions, () => controller.setTableOfContents(value))));
|
||||
if (["docx", "odt", "pdf"].includes(format)) new Setting(container).setName("Start chapters on a new page").addToggle((toggle) => toggle.setValue(formatting.chapterPageBreak).onChange((value) => invalidate(controller, actions, () => controller.setFormatting({ chapterPageBreak: value }))));
|
||||
if (format !== "markdown") new Setting(container).setName("Add table of contents").addToggle((toggle) => toggle.setValue(request.tableOfContents === true).onChange((value) => invalidate(controller, actions, () => controller.setTableOfContents(value))));
|
||||
if (["docx", "odt"].includes(format)) new Setting(container).setName("Start chapters on a new page").addToggle((toggle) => toggle.setValue(formatting.chapterPageBreak).onChange((value) => invalidate(controller, actions, () => controller.setFormatting({ chapterPageBreak: value }))));
|
||||
}
|
||||
|
||||
function renderWarnings(container: HTMLElement, warnings: ReturnType<typeof buildExportPreviewViewModel>["warnings"], ignoredNotes: number): void { container.createEl("h3", { text: "Warnings" }); const attention = attentionWarnings(warnings); container.createEl("p", { cls: "manuscript-warning-count", text: attention.length ? `${attention.length} item${attention.length === 1 ? "" : "s"} need attention` : "No issues requiring attention" }); if (attention.length) { const list = container.createEl("ul", { cls: "manuscript-warning-list" }); warningCategories(attention).forEach((category) => list.createEl("li", { text: category.count === 1 ? category.label : `${category.count} × ${category.label}` })); } const information = informationMessages(warnings); const details = container.createEl("details", { cls: "manuscript-information-summary" }); details.createEl("summary", { text: "Details" }); const list = details.createEl("ul"); information.forEach((item) => list.createEl("li", { text: item.message })); if (ignoredNotes) list.createEl("li", { text: `Project notes ignored — ${ignoredNotes.toLocaleString()}` }); if (!information.length && !ignoredNotes) list.createEl("li", { text: "No additional information" }); }
|
||||
|
||||
function renderAdvancedFormatting(container: HTMLElement, controller: CompileWorkspaceController, actions: CreateDocxStepActions, format: ExportFormat): void {
|
||||
const { request, formatting } = controller.state; const details = container.createEl("details", { cls: "manuscript-advanced-section" }); details.createEl("summary", { text: format === "xml" ? "Advanced content options" : "Advanced formatting" });
|
||||
const { request, formatting } = controller.state; const contentOnly = format === "xml" || format === "markdown"; const details = container.createEl("details", { cls: "manuscript-advanced-section" }); details.createEl("summary", { text: contentOnly ? "Advanced content options" : "Advanced formatting" });
|
||||
new Setting(details).setName("Book title override").addText((text) => text.setValue(request.custom?.variables?.BookTitle ?? "").onChange((value) => invalidate(controller, actions, () => controller.setVariable("BookTitle", value)))); new Setting(details).setName("Author override").addText((text) => text.setValue(request.custom?.variables?.Author ?? "").onChange((value) => invalidate(controller, actions, () => controller.setVariable("Author", value))));
|
||||
if (format === "xml") new Setting(details).setName("Include title page document").addToggle((toggle) => toggle.setValue(formatting.titlePage).onChange((value) => invalidate(controller, actions, () => controller.setFormatting({ titlePage: value }))));
|
||||
if (format !== "xml") { new Setting(details).setName("Font").addDropdown((dropdown) => { if (format === "pdf") dropdown.addOption("Times New Roman", "Serif (built-in PDF)").addOption("Arial", "Sans-serif (built-in PDF)"); else dropdown.addOption("Times New Roman", "Times New Roman").addOption("Garamond", "Garamond").addOption("Georgia", "Georgia").addOption("Arial", "Arial"); return dropdown.setValue(format === "pdf" && formatting.font !== "Arial" ? "Times New Roman" : formatting.font).onChange((font) => invalidate(controller, actions, () => controller.setFormatting({ font }))); }); new Setting(details).setName("Font size").addDropdown((dropdown) => dropdown.addOption("11", "11 pt").addOption("12", "12 pt").addOption("13", "13 pt").setValue(String(formatting.fontSize)).onChange((value) => invalidate(controller, actions, () => controller.setFormatting({ fontSize: Number(value) })))); new Setting(details).setName("Line spacing").addDropdown((dropdown) => dropdown.addOption("1", "Single").addOption("1.15", "1.15").addOption("1.5", "1.5 lines").addOption("2", "Double").setValue(String(formatting.lineSpacing)).onChange((value) => invalidate(controller, actions, () => controller.setFormatting({ lineSpacing: Number(value) })))); }
|
||||
if (["docx", "odt", "pdf"].includes(format)) { new Setting(details).setName("First-line indent (cm)").addDropdown((dropdown) => dropdown.addOption("0", "None").addOption("0.75", "0.75 cm").addOption("1.27", "1.27 cm").setValue(String(formatting.firstLineIndentCm)).onChange((value) => invalidate(controller, actions, () => controller.setFormatting({ firstLineIndentCm: Number(value) })))); new Setting(details).setName("Page size").addDropdown((dropdown) => dropdown.addOption("a4", "A4").addOption("letter", "Letter").setValue(formatting.pageSize).onChange((value) => invalidate(controller, actions, () => controller.setFormatting({ pageSize: value === "letter" ? "letter" : "a4" })))); }
|
||||
if (format === "pdf") new Setting(details).setName("Page margins (cm)").addDropdown((dropdown) => dropdown.addOption("1.5", "1.5 cm").addOption("2", "2 cm").addOption("2.54", "2.54 cm").addOption("3", "3 cm").setValue(String(formatting.pageMarginCm ?? 2.54)).onChange((value) => invalidate(controller, actions, () => controller.setFormatting({ pageMarginCm: Number(value) }))));
|
||||
if (!contentOnly) { new Setting(details).setName("Font").addDropdown((dropdown) => dropdown.addOption("Times New Roman", "Times New Roman").addOption("Garamond", "Garamond").addOption("Georgia", "Georgia").addOption("Arial", "Arial").setValue(formatting.font).onChange((font) => invalidate(controller, actions, () => controller.setFormatting({ font })))); new Setting(details).setName("Font size").addDropdown((dropdown) => dropdown.addOption("11", "11-point").addOption("12", "12-point").addOption("13", "13-point").setValue(String(formatting.fontSize)).onChange((value) => invalidate(controller, actions, () => controller.setFormatting({ fontSize: Number(value) })))); new Setting(details).setName("Line spacing").addDropdown((dropdown) => dropdown.addOption("1", "Single").addOption("1.15", "1.15").addOption("1.5", "1.5 lines").addOption("2", "Double").setValue(String(formatting.lineSpacing)).onChange((value) => invalidate(controller, actions, () => controller.setFormatting({ lineSpacing: Number(value) })))); }
|
||||
if (["docx", "odt"].includes(format)) new Setting(details).setName("Page size").addDropdown((dropdown) => dropdown.addOption("a4", "A4").addOption("letter", "Letter").setValue(formatting.pageSize).onChange((value) => invalidate(controller, actions, () => controller.setFormatting({ pageSize: value === "letter" ? "letter" : "a4" }))));
|
||||
if (format !== "xml") { const separator = effectiveSeparator(controller); new Setting(details).setName("Custom scene break").addText((text) => text.setValue(sceneBreakValues.has(separator) ? "" : separator).onChange((value) => invalidate(controller, actions, () => controller.setSceneSeparator(value)))); }
|
||||
displayChoice(details, "Part heading style", request.partDisplay ?? "word-title", (value) => invalidate(controller, actions, () => controller.setDisplay("part", value))); displayChoice(details, "Chapter heading style", request.chapterDisplay ?? "word-title", (value) => invalidate(controller, actions, () => controller.setDisplay("chapter", value)));
|
||||
new Setting(details).setName("Manuscript body headings").setDesc("Include each manuscript note title as a body heading.").addToggle((toggle) => toggle.setValue(request.custom?.includeSceneTitles === true).onChange((value) => invalidate(controller, actions, () => controller.setIncludeSceneTitles(value))));
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ export function renderManuscriptStep(container: HTMLElement, controller: Compile
|
|||
if (actions.selectedFromFileExplorer) book.createEl("p", { cls: "manuscript-selection-source", text: "Selected from File Explorer" });
|
||||
}
|
||||
const setting = new Setting(container).setName("Folder").setDesc(folder ? "Change the selected manuscript folder." : "No manuscript folder selected");
|
||||
setting.addButton((button) => button.setButtonText(folder ? "Change Folder" : "Choose Folder").setCta().onClick(actions.chooseFolder));
|
||||
if (showUseCurrentFolder(folder !== null, actions.selectedFromFileExplorer === true)) setting.addButton((button) => button.setButtonText("Use current folder").onClick(actions.useCurrentFolder));
|
||||
new Setting(container).setName("Detected structure").addDropdown((dropdown) => { Object.entries(STRUCTURE_PRESET_NAMES).forEach(([value, label]) => dropdown.addOption(value, label)); dropdown.setValue(state.request.structurePreset).onChange((value) => { controller.setPreset(value as StructurePreset); actions.changed(); }); });
|
||||
setting.addButton((button) => button.setButtonText(folder ? "Change folder" : "Choose folder").setCta().onClick(() => actions.chooseFolder()));
|
||||
if (showUseCurrentFolder(folder !== null, actions.selectedFromFileExplorer === true)) setting.addButton((button) => button.setButtonText("Use current folder").onClick(() => actions.useCurrentFolder()));
|
||||
new Setting(container).setName("Detected structure").addDropdown((dropdown) => { Object.entries(STRUCTURE_PRESET_NAMES).forEach(([value, label]) => { dropdown.addOption(value, label); }); dropdown.setValue(state.request.structurePreset).onChange((value) => { controller.setPreset(value as StructurePreset); actions.changed(); }); });
|
||||
if (folder && state.scannedRoot === folder.path && state.contentPlan.length) {
|
||||
const summary = manuscriptPlanSummary(state.contentPlan, folder.path); const compact = container.createDiv({ cls: "manuscript-scan-summary", attr: { "aria-label": "Manuscript scan summary" } });
|
||||
compact.createEl("p", { text: `${summary.totalNotes} note${summary.totalNotes === 1 ? "" : "s"}` });
|
||||
|
|
|
|||
10
styles.css
10
styles.css
|
|
@ -7,7 +7,7 @@
|
|||
.manuscript-compiler-report dt { font-weight: var(--font-semibold); }
|
||||
.manuscript-compiler-report dd { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.modal-content:has(.manuscript-compiler-report) {
|
||||
.manuscript-compiler-preview .modal-content {
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
|
@ -62,7 +62,12 @@
|
|||
.manuscript-contents-summary > div { padding: var(--size-4-2); border-radius: var(--radius-s); background: var(--background-secondary); }
|
||||
.manuscript-contents-summary strong, .manuscript-contents-summary span { display: block; }
|
||||
.manuscript-contents-summary span { color: var(--text-muted); font-size: var(--font-ui-smaller); }
|
||||
.manuscript-contents-toolbar { display: flex; justify-content: flex-end; margin-bottom: var(--size-4-3); }
|
||||
.manuscript-contents-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--size-4-2) var(--size-4-4); margin-bottom: var(--size-4-4); padding: var(--size-4-3); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-secondary); }
|
||||
.manuscript-contents-toolbar p { flex: 1 1 240px; margin: 0; color: var(--text-muted); }
|
||||
.manuscript-correct-structure { display: inline-flex; align-items: center; justify-content: center; gap: var(--size-4-2); min-height: 44px; padding: var(--size-4-2) var(--size-4-5); font-size: var(--font-ui-medium); font-weight: var(--font-semibold); }
|
||||
.manuscript-correct-structure-icon { display: inline-flex; }
|
||||
.manuscript-correct-structure-icon svg { width: 18px; height: 18px; }
|
||||
.manuscript-correct-structure.is-active { box-shadow: 0 0 0 2px var(--interactive-accent-hover), inset 0 0 0 1px var(--text-on-accent); }
|
||||
.manuscript-review-sections { display: grid; gap: var(--size-4-2); margin: var(--size-4-3) 0; }
|
||||
.manuscript-review-row { display: grid; grid-template-columns: minmax(0, 1fr) max-content max-content; align-items: center; gap: var(--size-4-3); padding: var(--size-4-2) var(--size-4-3); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); }
|
||||
.manuscript-compact-outline { border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); overflow: hidden; }
|
||||
|
|
@ -146,3 +151,4 @@
|
|||
.manuscript-compile-body .setting-item { flex-wrap: wrap; }
|
||||
.manuscript-compile-body .setting-item-control { width: 100%; justify-content: flex-start; flex-wrap: wrap; }
|
||||
}
|
||||
.manuscript-download-anchor { display: none; }
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const loaded = await loadFixtureTree("samples/Book 1 - Warden of Silence");
|
|||
const profile = createDefaultProfiles()[1]; profile.useParts = true; profile.chapterSource = "folders"; profile.sceneSeparator = "#"; profile.partDisplay = "word-title"; profile.chapterDisplay = "word-title"; profile.bodySectionAliases = ["Scene", "Manuscript", "Text", "Draft", "Body"];
|
||||
const session = await new CompilePreparationService(loaded.vault as never, profile, 250).prepareAuthoritative({ manuscriptRoot: loaded.root.path, profile, structurePreset: "novel-parts", purpose: "compile", route: "legacy-profile" });
|
||||
const { book } = session;
|
||||
const baseOptions: DocxOptions = { title: "Warden of Silence", author: "Anthony Fitzpatrick", titlePage: true, font: "Times New Roman", fontSize: 12, lineSpacing: 2, firstLineIndentCm: 1.27, partDisplay: "word-title", chapterDisplay: "word-title", chapterPageBreak: true };
|
||||
const baseOptions: DocxOptions = { title: "Warden of Silence", author: "Anthony Fitzpatrick", titlePage: true, font: "Times New Roman", fontSize: 12, lineSpacing: 2, indentParagraphs: true, firstLineIndentCm: 1.27, partDisplay: "word-title", chapterDisplay: "word-title", chapterPageBreak: true };
|
||||
const bytes = createManuscriptDocx(book, profile, baseOptions);
|
||||
assert.equal(validateDocxBytes(bytes).valid, true);
|
||||
assert.equal(String.fromCharCode(...bytes.slice(0, 2)), "PK");
|
||||
|
|
@ -37,6 +37,7 @@ const paragraphs = (xml: string, style: string): string[] => [...xml.matchAll(ne
|
|||
const paragraphText = (xml: string): string => xml.replace(/<[^>]+>/g, "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'");
|
||||
const documentFor = (options: Partial<DocxOptions> = {}, selectedProfile = profile, selectedBook = book): string => strFromU8(unzipSync(createManuscriptDocx(selectedBook, selectedProfile, { ...baseOptions, ...options }))["word/document.xml"]);
|
||||
const stylesFor = (options: Partial<DocxOptions> = {}): string => strFromU8(unzipSync(createManuscriptDocx(book, profile, { ...baseOptions, ...options }))["word/styles.xml"]);
|
||||
const paragraphStyle = (xml: string, style: string): string => new RegExp(`<w:style w:type="paragraph" w:styleId="${style}">[\\s\\S]*?<\\/w:style>`).exec(xml)?.[0] ?? "";
|
||||
|
||||
const breaksEnabled = documentFor({ chapterPageBreak: true });
|
||||
assert.ok(paragraphs(breaksEnabled, "ChapterNumber").every((item) => item.includes("<w:pageBreakBefore/>")));
|
||||
|
|
@ -128,9 +129,14 @@ assert.match(customStyles, /w:styleId="BodyText"[\s\S]*?w:ind w:firstLine="360"/
|
|||
assert.match(customStyles, /w:styleId="FirstParagraph"[\s\S]*?w:ind w:firstLine="0"/);
|
||||
assert.match(stylesFor({ firstLineIndentCm: 0.75 }), /w:styleId="BodyText"[\s\S]*?w:ind w:firstLine="425"/);
|
||||
assert.match(stylesFor({ firstLineIndentCm: 1.27 }), /w:styleId="BodyText"[\s\S]*?w:ind w:firstLine="720"/);
|
||||
const indentationDisabledStyles = stylesFor({ indentParagraphs: false, firstLineIndentCm: 1.27 });
|
||||
assert.match(paragraphStyle(indentationDisabledStyles, "BodyText"), /w:ind w:firstLine="0"/);
|
||||
assert.match(paragraphStyle(indentationDisabledStyles, "FirstParagraph"), /w:ind w:firstLine="0"/);
|
||||
for (const style of ["Title", "Author", "PartNumber", "PartTitle", "ChapterNumber", "ChapterTitle", "SceneBreak", "FrontMatterHeading", "BackMatterHeading"]) assert.equal(paragraphStyle(indentationDisabledStyles, style), paragraphStyle(styles, style), `${style} must not change with paragraph indentation`);
|
||||
assert.equal(centimetresToTwips(0.75), 425); assert.equal(centimetresToTwips(1.27), 720); assert.equal(centimetresToTwips(2.54), 1440);
|
||||
assert.match(documentFor({ pageSize: "a4" }), /w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/);
|
||||
assert.deepEqual(resolveDocxOptions({ title: "x", author: "y", font: "", fontSize: 100, lineSpacing: -2, firstLineIndentCm: 9, pageSize: "a4" }), { title: "x", author: "y", font: "Times New Roman", fontSize: 24, lineSpacing: 0.8, firstLineIndentCm: 3.81, pageSize: "a4", chapterPageBreak: true, titlePage: false, tableOfContents: false });
|
||||
assert.deepEqual(resolveDocxOptions({ title: "x", author: "y", font: "", fontSize: 100, lineSpacing: -2, firstLineIndentCm: 9, pageSize: "a4" }), { title: "x", author: "y", font: "Times New Roman", fontSize: 24, lineSpacing: 0.8, indentParagraphs: true, firstLineIndentCm: 3.81, pageSize: "a4", chapterPageBreak: true, titlePage: false, tableOfContents: false });
|
||||
assert.equal(resolveDocxOptions({ title: "x", author: "y", indentParagraphs: false }).indentParagraphs, false);
|
||||
assert.equal(resolveDocxOptions({ title: "x", author: "y" }).pageSize, "a4");
|
||||
|
||||
const richScene = { ...book.parts[0].chapters[0].scenes[0], content: "**bold** *italic* ***both*** [readable link](https://example.invalid) `code` & <angle> “smart”—Östersund 雪" };
|
||||
|
|
|
|||
143
tests/exports.ts
143
tests/exports.ts
|
|
@ -1,5 +1,6 @@
|
|||
/** Native multi-format generation, validation, and browser-delivery regression suite. */
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { strFromU8, unzipSync } from "fflate";
|
||||
import { BrowserDownloadService, type DownloadEnvironment } from "../src/browser-download";
|
||||
import { CompilePreparationService } from "../src/compile-preparation";
|
||||
|
|
@ -7,9 +8,9 @@ import { EXPORT_FORMAT_DETAILS, EXPORT_FORMATS, type ExportFormat, type Manuscri
|
|||
import { exportFilename } from "../src/export-filename";
|
||||
import { EXPORTERS } from "../src/native-exporters";
|
||||
import { EXPORT_VALIDATORS } from "../src/export-validators";
|
||||
import { createNativePdf, escapePdfLiteral, measureNativePdfText, pdfPageDimensions, pdfPointsFromCm, recoverNativePdfText, wrapNativePdfText } from "../src/native-pdf";
|
||||
import { renderSemanticMarkdown } from "../src/markdown-exporter";
|
||||
import { createDefaultProfiles } from "../src/profiles";
|
||||
import { createSemanticDocument } from "../src/semantic-document";
|
||||
import { createSemanticDocument, inlineMarkdown, type SemanticDocument } from "../src/semantic-document";
|
||||
import { createContentPlan, classifyContentPlan } from "../src/content-plan";
|
||||
import { loadFixtureTree } from "./fixture-loader";
|
||||
import { ExportCoordinator } from "../src/export-coordinator";
|
||||
|
|
@ -22,112 +23,114 @@ const tests: Array<[string, ExportFormat | "common", () => void | Promise<void>]
|
|||
const test = (name: string, format: ExportFormat | "common", action: () => void | Promise<void>): void => { tests.push([name, format, action]); };
|
||||
|
||||
const loaded = await loadFixtureTree("samples/Book 1 - Warden of Silence");
|
||||
const profile = createDefaultProfiles()[1]; profile.useParts = true; profile.sceneSeparator = "#"; profile.partDisplay = "word-title"; profile.chapterDisplay = "word-title";
|
||||
const profile = createDefaultProfiles()[1]; profile.useParts = true; profile.includeSceneTitles = true; profile.sceneSeparator = "#"; profile.partDisplay = "word-title"; profile.chapterDisplay = "word-title";
|
||||
const plan = await classifyContentPlan(loaded.vault as never, createContentPlan(loaded.root, "novel-parts"));
|
||||
const session = await new CompilePreparationService(loaded.vault as never, profile, 250).prepare({ manuscriptRoot: loaded.root.path, structurePreset: "novel-parts", includeFrontMatter: true, includeBackMatter: true, exportFolder: "", outputFilename: "Warden of Silence.docx", outputFormat: "docx", docxPreset: "vellum", contentPlan: plan, formatting: { font: "Times New Roman", fontSize: 12, lineSpacing: 1.5, firstLineIndentCm: 0.75, pageSize: "a4", chapterPageBreak: true, titlePage: true }, tableOfContents: true, custom: { variables: { BookTitle: "Warden & <Silence> 雪", Author: "A & B 'Writer'" } } }, plan);
|
||||
const options = { title: "Warden & <Silence> 雪", author: "A & B 'Writer'", language: "en", titlePage: true, tableOfContents: true, font: "Times New Roman", fontSize: 12, lineSpacing: 1.5, firstLineIndentCm: 0.75, pageSize: "a4" as const, pageMarginCm: 2.54, chapterPageBreak: true, sceneSeparator: "#" };
|
||||
const session = await new CompilePreparationService(loaded.vault as never, profile, 250).prepare({ manuscriptRoot: loaded.root.path, structurePreset: "novel-parts", includeFrontMatter: true, includeBackMatter: true, exportFolder: "", outputFilename: "Warden of Silence.docx", outputFormat: "docx", docxPreset: "vellum", contentPlan: plan, formatting: { font: "Times New Roman", fontSize: 12, lineSpacing: 1.5, indentParagraphs: true, firstLineIndentCm: 0.75, pageSize: "a4", chapterPageBreak: true, titlePage: true }, tableOfContents: true, custom: { variables: { BookTitle: "Warden & <Silence> 雪", Author: "A & B 'Writer'" } } }, plan);
|
||||
const options = { title: "Warden & <Silence> 雪", author: "A & B 'Writer'", language: "en", titlePage: true, tableOfContents: true, font: "Times New Roman", fontSize: 12, lineSpacing: 1.5, indentParagraphs: true, firstLineIndentCm: 0.75, pageSize: "a4" as const, chapterPageBreak: true, sceneSeparator: "#" };
|
||||
const document = createSemanticDocument(session.book, profile, options, session.statistics.totalWordCount);
|
||||
const generated = new Map<ExportFormat, Awaited<ReturnType<(typeof EXPORTERS)[ExportFormat]["generate"]>>>();
|
||||
async function output(format: ExportFormat) { let value = generated.get(format); if (!value) { const context: ManuscriptExportContext = { session, document, options, filename: exportFilename("Warden.docx", format) }; value = await EXPORTERS[format].generate(context); generated.set(format, value); } return value; }
|
||||
async function outputWithIndent(format: ExportFormat, indentParagraphs: boolean) { return EXPORTERS[format].generate({ session, document, options: { ...options, indentParagraphs }, filename: exportFilename("Warden.docx", format) }); }
|
||||
|
||||
function effectiveFontWeight(css: string, tag: string, className: string): string | undefined {
|
||||
let winner: { specificity: number; order: number; value: string } | undefined; let order = 0;
|
||||
for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
const weight = /(?:^|;)font-weight:([^;}]+)/.exec(rule[2])?.[1]?.trim(); if (!weight) continue;
|
||||
for (const rawSelector of rule[1].split(",")) { const selector = rawSelector.trim(); const matches = selector === tag || selector === `.${className}` || selector === `${tag}.${className}`; if (!matches) continue; const specificity = (selector.match(/\./g)?.length ?? 0) * 10 + (/^[a-z]/i.test(selector) ? 1 : 0); if (!winner || specificity > winner.specificity || specificity === winner.specificity && order >= winner.order) winner = { specificity, order, value: weight }; order += 1; }
|
||||
}
|
||||
return winner?.value;
|
||||
}
|
||||
function genericHeadingWeightSelectors(css: string): string[] { const selectors: string[] = []; for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { if (!/(?:^|;)font-weight:/.test(rule[2])) continue; selectors.push(...rule[1].split(",").map((selector) => selector.trim()).filter((selector) => /^h[1-3]$/.test(selector))); } return selectors; }
|
||||
function effectiveTextIndent(css: string, className: string): string | undefined { let value: string | undefined; for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { if (!rule[1].split(",").map((selector) => selector.trim()).includes(`.${className}`)) continue; const indent = /(?:^|;)text-indent:([^;}]+)/.exec(rule[2])?.[1]?.trim(); if (indent) value = indent; } return value; }
|
||||
|
||||
const separateHeadingDocument: SemanticDocument = { title: "Heading modes", author: "Author", language: "en", wordCount: 4, sections: [
|
||||
{ id: "part-number", kind: "part", title: "Part One", number: 1, blocks: [{ kind: "heading", style: "part-number", inlines: [{ text: "Part One" }] }] },
|
||||
{ id: "part-title", kind: "part", title: "The Silence Breaks", blocks: [{ kind: "heading", style: "part-title", inlines: [{ text: "The Silence Breaks" }] }] },
|
||||
{ id: "chapter-number", kind: "chapter", title: "Chapter One", number: 1, blocks: [{ kind: "heading", style: "chapter-number", inlines: [{ text: "Chapter One" }] }] },
|
||||
{ id: "chapter-title", kind: "chapter", title: "The Silence of Östersund", blocks: [{ kind: "heading", style: "chapter-title", inlines: [{ text: "The Silence of Östersund" }] }] }
|
||||
] };
|
||||
async function separateHeadingOutput(format: "html" | "epub") { return EXPORTERS[format].generate({ session, document: separateHeadingDocument, options, filename: `heading-modes.${format}` }); }
|
||||
|
||||
test("all exporters share one prepared Book projection and expose correct MIME metadata", "common", async () => {
|
||||
assert.equal(document.sections.length > 0, true);
|
||||
for (const format of EXPORT_FORMATS) { const value = await output(format); assert.equal(value.format, format); assert.equal(value.mimeType, EXPORT_FORMAT_DETAILS[format].mimeType); assert.equal(value.filename, `Warden.${EXPORT_FORMAT_DETAILS[format].extension}`); assert.equal(EXPORT_VALIDATORS[format].validate(value.bytes).valid, true); }
|
||||
for (const format of EXPORT_FORMATS) { const value = await output(format); assert.equal(value.format, format); assert.equal(value.mimeType, EXPORT_FORMAT_DETAILS[format].mimeType); assert.equal(value.filename, `Warden.${EXPORT_FORMAT_DETAILS[format].extension}`); assert.equal(EXPORT_VALIDATORS[format].validate(value.bytes, { session, document, options, filename: value.filename }).valid, true); }
|
||||
});
|
||||
|
||||
test("portable filenames correct paths, duplicate extensions, and Windows reserved names", "common", () => {
|
||||
assert.equal(exportFilename("folder/book.docx", "pdf"), "book.pdf"); assert.equal(exportFilename("book.HTML", "html"), "book.html"); assert.equal(exportFilename("book.epub.epub", "epub"), "book.epub"); assert.equal(exportFilename("CON", "xml"), "_CON.xml");
|
||||
assert.equal(exportFilename("folder/book.docx", "markdown"), "book.md"); assert.equal(exportFilename("book.HTML", "html"), "book.html"); assert.equal(exportFilename("book.epub.epub", "epub"), "book.epub"); assert.equal(exportFilename("CON", "xml"), "_CON.xml");
|
||||
});
|
||||
|
||||
test("browser download clicks once, uses properties, removes the anchor, and revokes its URL", "common", async () => {
|
||||
let clicks = 0; let removes = 0; const revoked: string[] = []; let appended = 0; const anchor = { href: "", download: "", style: { display: "" }, click: () => { clicks += 1; }, remove: () => { removes += 1; } } as unknown as HTMLAnchorElement;
|
||||
const environment: DownloadEnvironment = { createObjectURL: (blob) => { assert.equal(blob.type, "application/pdf"); return "blob:test"; }, revokeObjectURL: (url) => revoked.push(url), createAnchor: () => anchor, append: () => { appended += 1; }, defer: (action) => action() };
|
||||
const result = await new BrowserDownloadService(environment).download({ filename: "folder/book.pdf", bytes: new Uint8Array([1, 2, 3]), mimeType: "application/pdf" });
|
||||
assert.deepEqual(result, { started: true, filename: "book.pdf" }); assert.equal(anchor.href, "blob:test"); assert.equal(anchor.download, "book.pdf"); assert.equal(clicks, 1); assert.equal(appended, 1); assert.equal(removes, 1); assert.deepEqual(revoked, ["blob:test"]);
|
||||
let clicks = 0; let removes = 0; let hiddenClass = false; const revoked: string[] = []; let appended = 0; const anchor = { href: "", download: "", addClass: (name: string) => { hiddenClass = name === "manuscript-download-anchor"; }, click: () => { clicks += 1; }, remove: () => { removes += 1; } } as unknown as HTMLAnchorElement;
|
||||
const environment: DownloadEnvironment = { createObjectURL: (blob) => { assert.equal(blob.type, "text/markdown;charset=utf-8"); return "blob:test"; }, revokeObjectURL: (url) => revoked.push(url), createAnchor: () => anchor, append: () => { appended += 1; }, defer: (action) => action() };
|
||||
const result = await new BrowserDownloadService(environment).download({ filename: "folder/book.md", bytes: new Uint8Array([1, 2, 3]), mimeType: "text/markdown;charset=utf-8" });
|
||||
assert.deepEqual(result, { started: true, filename: "book.md" }); assert.equal(anchor.href, "blob:test"); assert.equal(anchor.download, "book.md"); assert.equal(hiddenClass, true); assert.equal(clicks, 1); assert.equal(appended, 1); assert.equal(removes, 1); assert.deepEqual(revoked, ["blob:test"]);
|
||||
});
|
||||
|
||||
test("browser download cleans up synchronously when dispatch fails", "common", async () => {
|
||||
let removed = false; let revoked = false; const anchor = { href: "", download: "", style: {}, click: () => { throw new Error("blocked"); }, remove: () => { removed = true; } } as unknown as HTMLAnchorElement;
|
||||
let removed = false; let revoked = false; const anchor = { href: "", download: "", addClass: () => undefined, click: () => { throw new Error("blocked"); }, remove: () => { removed = true; } } as unknown as HTMLAnchorElement;
|
||||
const result = await new BrowserDownloadService({ createObjectURL: () => "blob:failed", revokeObjectURL: () => { revoked = true; }, createAnchor: () => anchor, append: () => undefined, defer: () => assert.fail("failed downloads must not defer cleanup") }).download({ filename: "book.xml", bytes: new Uint8Array([1]), mimeType: "application/xml" });
|
||||
assert.equal(result.started, false); assert.equal(removed, true); assert.equal(revoked, true);
|
||||
});
|
||||
|
||||
test("coordinator blocks invalid bytes before download dispatch", "common", async () => {
|
||||
const original = EXPORTERS.pdf; let downloads = 0; EXPORTERS.pdf = { format: "pdf", generate: async (context) => ({ format: "pdf", filename: context.filename, mimeType: "application/pdf", bytes: new Uint8Array([1, 2, 3]), warnings: [] }) };
|
||||
const settings = { ...DEFAULT_SETTINGS, profiles: [profile], exportHistory: [], compileLogs: [], defaultDownloadFormat: "pdf" as const }; const history = new CompileHistoryService(() => settings, async () => undefined, "0.9.2"); const coordinator = new ExportCoordinator({ vault: loaded.vault } as never, () => settings, async () => undefined, new OperationStateController(), history, { download: async () => { downloads += 1; return { started: true, filename: "Warden.pdf" }; } } as never);
|
||||
try { const result = await coordinator.exportPreparedSession(session, { format: "pdf", filename: "Warden.pdf", showResult: false }); assert.equal(result.status, "failed"); assert.equal(result.validationPassed, false); assert.equal(downloads, 0); assert.equal(settings.exportHistory[0].downloadStarted, false); }
|
||||
finally { EXPORTERS.pdf = original; }
|
||||
const original = EXPORTERS.html; let downloads = 0; EXPORTERS.html = { format: "html", generate: async (context) => ({ format: "html", filename: context.filename, mimeType: "text/html;charset=utf-8", bytes: new Uint8Array([1, 2, 3]), warnings: [] }) };
|
||||
const settings = { ...DEFAULT_SETTINGS, profiles: [profile], exportHistory: [], compileLogs: [], defaultDownloadFormat: "html" as const }; const history = new CompileHistoryService(() => settings, async () => undefined, "0.9.2"); const coordinator = new ExportCoordinator({ vault: loaded.vault } as never, () => settings, async () => undefined, new OperationStateController(), history, { download: async () => { downloads += 1; return { started: true, filename: "Warden.html" }; } } as never);
|
||||
try { const result = await coordinator.exportPreparedSession(session, { format: "html", filename: "Warden.html", showResult: false }); assert.equal(result.status, "failed"); assert.equal(result.validationPassed, false); assert.equal(downloads, 0); assert.equal(settings.exportHistory[0].downloadStarted, false); }
|
||||
finally { EXPORTERS.html = original; }
|
||||
});
|
||||
|
||||
test("coordinator validates once, starts one download, and records format without a path", "common", async () => {
|
||||
let downloads = 0; const settings = { ...DEFAULT_SETTINGS, profiles: [profile], exportHistory: [], compileLogs: [], defaultDownloadFormat: "html" as const }; const history = new CompileHistoryService(() => settings, async () => undefined, "0.9.2"); const coordinator = new ExportCoordinator({ vault: loaded.vault } as never, () => settings, async () => undefined, new OperationStateController(), history, { download: async (request: { filename: string }) => { downloads += 1; return { started: true, filename: request.filename }; } } as never);
|
||||
const result = await coordinator.exportPreparedSession(session, { format: "html", filename: "folder/Warden.docx", showResult: false }); assert.equal(result.status, "success", result.error); assert.equal(downloads, 1); assert.equal(settings.exportHistory[0].format, "html"); assert.equal(settings.exportHistory[0].downloadStarted, true); assert.deepEqual(settings.exportHistory[0].outputFiles, ["Warden.html"]); assert.doesNotMatch(JSON.stringify(settings.exportHistory[0]), /blob:|\/Users\/|\\Users\\/);
|
||||
let downloads = 0; const settings = { ...DEFAULT_SETTINGS, profiles: [profile], exportHistory: [], compileLogs: [], defaultDownloadFormat: "html" as const, defaultIndentParagraphs: false }; const history = new CompileHistoryService(() => settings, async () => undefined, "0.9.2"); const coordinator = new ExportCoordinator({ vault: loaded.vault } as never, () => settings, async () => undefined, new OperationStateController(), history, { download: async (request: { filename: string }) => { downloads += 1; return { started: true, filename: request.filename }; } } as never);
|
||||
const result = await coordinator.exportPreparedSession(session, { format: "html", filename: "folder/Warden.docx", showResult: false }); assert.equal(result.status, "success", result.error); assert.equal(downloads, 1); assert.equal(settings.defaultIndentParagraphs, true); assert.equal(settings.exportHistory[0].format, "html"); assert.equal(settings.exportHistory[0].downloadStarted, true); assert.deepEqual(settings.exportHistory[0].outputFiles, ["Warden.html"]); assert.doesNotMatch(JSON.stringify(settings.exportHistory[0]), /blob:|\/Users\/|\\Users\\/);
|
||||
});
|
||||
|
||||
test("DOCX remains a validated native OOXML package", "docx", async () => { const value = await output("docx"); const files = unzipSync(value.bytes); assert.ok(files["word/document.xml"]); assert.match(strFromU8(files["word/document.xml"]), /Part One/); });
|
||||
test("DOCX applies the configured indent only to Body Text when enabled", "docx", async () => { const value = await output("docx"); const files = unzipSync(value.bytes); assert.ok(files["word/document.xml"]); assert.match(strFromU8(files["word/document.xml"]), /Part One/); const enabled = strFromU8(files["word/styles.xml"]); const disabled = strFromU8(unzipSync((await outputWithIndent("docx", false)).bytes)["word/styles.xml"]); const style = (xml: string, name: string): string => new RegExp(`<w:style w:type="paragraph" w:styleId="${name}">[\\s\\S]*?<\\/w:style>`).exec(xml)?.[0] ?? ""; assert.match(style(enabled, "BodyText"), /w:ind w:firstLine="425"/); assert.match(style(enabled, "FirstParagraph"), /w:ind w:firstLine="0"/); assert.match(style(disabled, "BodyText"), /w:ind w:firstLine="0"/); assert.match(style(disabled, "FirstParagraph"), /w:ind w:firstLine="0"/); for (const name of ["Title", "Author", "PartNumber", "PartTitle", "ChapterNumber", "ChapterTitle", "SceneBreak", "FrontMatterHeading", "BackMatterHeading"]) assert.equal(style(disabled, name), style(enabled, name), name); });
|
||||
|
||||
test("ODT has required native package entries, first mimetype, styles, Unicode, and escaped XML", "odt", async () => { const value = await output("odt"); const files = unzipSync(value.bytes); for (const name of ["mimetype", "META-INF/manifest.xml", "content.xml", "styles.xml", "meta.xml", "settings.xml"]) assert.ok(files[name]); const content = strFromU8(files["content.xml"]); assert.match(content, /Östersund/); assert.match(strFromU8(files["meta.xml"]), /Warden & <Silence> 雪/); assert.doesNotMatch(content, /Revision Notes|Internal synopsis/); });
|
||||
test("ODT toggles only BodyText indentation and preserves structural styles", "odt", async () => { const value = await output("odt"); const files = unzipSync(value.bytes); for (const name of ["mimetype", "META-INF/manifest.xml", "content.xml", "styles.xml", "meta.xml", "settings.xml"]) assert.ok(files[name]); const content = strFromU8(files["content.xml"]); const styles = strFromU8(files["styles.xml"]); const disabledStyles = strFromU8(unzipSync((await outputWithIndent("odt", false)).bytes)["styles.xml"]); const style = (xml: string, name: string): string => new RegExp(`<style:style style:name="${name}"[\\s\\S]*?<\\/style:style>`).exec(xml)?.[0] ?? ""; assert.match(style(styles, "BodyText"), /fo:text-indent="0\.75cm"/); assert.match(style(styles, "FirstParagraph"), /fo:text-indent="0cm"/); assert.match(style(disabledStyles, "BodyText"), /fo:text-indent="0cm"/); assert.match(style(disabledStyles, "FirstParagraph"), /fo:text-indent="0cm"/); for (const name of ["Title", "FrontMatterHeading", "PartNumber", "PartTitle", "ChapterNumber", "ChapterTitle", "BackMatterHeading"]) { assert.match(style(styles, name), /fo:font-weight="bold"/, `${name} must explicitly declare bold`); assert.equal(style(disabledStyles, name), style(styles, name), `${name} must not change`); } for (const name of ["Author", "FirstParagraph", "BodyText", "SceneBreak"]) assert.doesNotMatch(style(styles, name), /fo:font-weight="bold"/, `${name} must remain non-bold`); assert.match(content, /<text:p text:style-name="Title">Warden & <Silence> 雪<\/text:p>/); assert.match(content, /<text:p text:style-name="FrontMatterHeading">[^<]+<\/text:p>/); assert.match(content, /<text:p text:style-name="PartNumber">Part One<\/text:p><text:p text:style-name="PartTitle">The Silence Breaks<\/text:p>/); assert.match(content, /<text:p text:style-name="ChapterNumber">Chapter One<\/text:p><text:p text:style-name="ChapterTitle">The Silence of Östersund<\/text:p>/); assert.match(content, /<text:p text:style-name="BackMatterHeading">[^<]+<\/text:p>/); assert.match(content, /<text:p text:style-name="FirstParagraph">Östersund was silent/); assert.match(content, /<text:p text:style-name="SceneBreak">#<\/text:p><text:p text:style-name="BodyHeading">Scene 2[^<]*<\/text:p><text:p text:style-name="FirstParagraph">The barn door opened/); assert.doesNotMatch(content, /<style:style\b/, "content.xml must not override named heading styles with automatic styles"); assert.match(strFromU8(files["meta.xml"]), /Warden & <Silence> 雪/); assert.doesNotMatch(content, /Revision Notes|Internal synopsis/); });
|
||||
|
||||
test("PDF has searchable WinAnsi text, an exact ToUnicode map, proportional layout and A4 margins", "pdf", async () => { const value = await output("pdf"); const text = new TextDecoder("latin1").decode(value.bytes); assert.match(text, /^%PDF-1\.7/); assert.match(text, /\/MediaBox \[0 0 595\.28 841\.89\]/); assert.match(text, /\/MCLayout << \/LeftMargin 72\.00 \/RightMargin 72\.00[\s\S]*\/UsableWidth 451\.28/); assert.match(text, /\/Subtype \/Type1 \/BaseFont \/Times-Roman \/Encoding \/WinAnsiEncoding/); assert.match(text, /\/Subtype \/Type1 \/BaseFont \/Times-Bold \/Encoding \/WinAnsiEncoding/); assert.doesNotMatch(text, /\/Identity-H|\/CIDToGIDMap \/Identity/); assert.match(text, /BT \/F2 24\.00 Tf 30\.00 TL/); assert.match(text, /D67374657273756E64/); assert.match(text, /<D6> <00D6>/); assert.match(recoverNativePdfText(value.bytes), /Östersund/); assert.match(text, /%%EOF$/); const alternate = await EXPORTERS.pdf.generate({ session, document, options: { ...options, font: "Arial", pageMarginCm: 2 }, filename: "alternate.pdf" }); const alternateText = new TextDecoder("latin1").decode(alternate.bytes); assert.match(alternateText, /\/BaseFont \/Helvetica/); assert.match(alternateText, /\/BaseFont \/Helvetica-Bold/); assert.match(alternateText, /\/LeftMargin 56\.69 \/RightMargin 56\.69/); });
|
||||
test("EPUB applies paragraph indentation through classes without changing structural styling", "epub", async () => { const value = await output("epub"); const files = unzipSync(value.bytes); for (const name of ["mimetype", "META-INF/container.xml", "OEBPS/content.opf", "OEBPS/nav.xhtml", "OEBPS/style.css"]) assert.ok(files[name]); const css = strFromU8(files["OEBPS/style.css"]); const all = Object.entries(files).filter(([name]) => name.endsWith(".xhtml")).map(([, bytes]) => strFromU8(bytes)).join("\n"); const disabledFiles = unzipSync((await outputWithIndent("epub", false)).bytes); const disabledCss = strFromU8(disabledFiles["OEBPS/style.css"]); assert.equal(effectiveTextIndent(css, "body-text"), "0.75cm"); assert.equal(effectiveTextIndent(css, "first-paragraph"), "0"); assert.equal(effectiveTextIndent(disabledCss, "body-text"), "0cm"); assert.equal(effectiveTextIndent(disabledCss, "first-paragraph"), "0"); assert.equal(effectiveTextIndent(css, "scene-break"), undefined); assert.doesNotMatch(all, /\sstyle=/i); assert.match(all, /<h1 class="manuscript-title">/); assert.match(all, /<h2 class="manuscript-matter-heading">/); assert.match(all, /<h1 class="manuscript-part-heading">Part One — The Silence Breaks<\/h1>/); assert.match(all, /<h2 class="manuscript-chapter-heading">Chapter One — The Silence of Östersund<\/h2>/); for (const [tag, className] of [["h1", "manuscript-title"], ["h2", "manuscript-matter-heading"], ["h1", "manuscript-part-heading"], ["h2", "manuscript-chapter-heading"]]) assert.equal(effectiveFontWeight(css, tag, className), "700", className); assert.deepEqual(genericHeadingWeightSelectors(css), []); assert.doesNotMatch(css, /font-weight:(?:normal|400)|(?:^|[;}])font:/); assert.match(all, /<p class="first-paragraph">Östersund was silent/); assert.match(all, /<div class="scene-break" role="separator">#<\/div><h3 class="manuscript-body-heading">Scene 2[^<]*<\/h3><p class="first-paragraph">The barn door opened/); assert.doesNotMatch(all, /<p class="(?:first-paragraph|body-text)"><strong>Östersund/); const separateFiles = unzipSync((await separateHeadingOutput("epub")).bytes); const separateCss = strFromU8(separateFiles["OEBPS/style.css"]); const separate = Object.entries(separateFiles).filter(([name]) => /^OEBPS\/section-\d+\.xhtml$/.test(name)).map(([, bytes]) => strFromU8(bytes)).join("\n"); for (const [tag, className, expected] of [["h1", "manuscript-part-number", /<h1 class="manuscript-part-number">Part One<\/h1>/], ["h1", "manuscript-part-title", /<h1 class="manuscript-part-title">The Silence Breaks<\/h1>/], ["h2", "manuscript-chapter-number", /<h2 class="manuscript-chapter-number">Chapter One<\/h2>/], ["h2", "manuscript-chapter-title", /<h2 class="manuscript-chapter-title">The Silence of Östersund<\/h2>/]] as const) { assert.match(separate, expected); assert.equal(effectiveFontWeight(separateCss, tag, className), "700", className); } assert.doesNotMatch(all, /<script\b|(?:src|href)=["']https?:/i); assert.doesNotMatch(all, /Revision Notes|Internal synopsis/); });
|
||||
|
||||
test("PDF units and proportional built-in font metrics are correct", "pdf", () => {
|
||||
const a4 = pdfPageDimensions("a4"); const letter = pdfPageDimensions("letter"); assert.ok(Math.abs(a4.width - 595.28) < 0.01); assert.ok(Math.abs(a4.height - 841.89) < 0.01); assert.deepEqual(letter, { width: 612, height: 792 }); assert.ok(Math.abs(pdfPointsFromCm(2.54) - 72) < 0.001); assert.ok(Math.abs(pdfPointsFromCm(1.27) - 36) < 0.001); assert.ok(Math.abs(pdfPointsFromCm(0.75) - 21.26) < 0.01);
|
||||
assert.ok(measureNativePdfText("iiiiiiiiii", "Times-Roman", 12) < measureNativePdfText("WWWWWWWWWW", "Times-Roman", 12)); assert.ok(measureNativePdfText("The Silence of Östersund", "Times-Roman", 12) > 100); assert.ok(measureNativePdfText("A narrow sentence.", "Times-Roman", 12) < measureNativePdfText("A much wider sentence — with punctuation.", "Times-Roman", 12));
|
||||
test("HTML applies paragraph indentation through classes without changing structural styling", "html", async () => { const text = strFromU8((await output("html")).bytes); const css = /<style>([\s\S]+)<\/style>/.exec(text)?.[1] ?? ""; const disabledText = strFromU8((await outputWithIndent("html", false)).bytes); const disabledCss = /<style>([\s\S]+)<\/style>/.exec(disabledText)?.[1] ?? ""; assert.equal(effectiveTextIndent(css, "body-text"), "0.75cm"); assert.equal(effectiveTextIndent(css, "first-paragraph"), "0"); assert.equal(effectiveTextIndent(disabledCss, "body-text"), "0cm"); assert.equal(effectiveTextIndent(disabledCss, "first-paragraph"), "0"); assert.equal(effectiveTextIndent(css, "scene-break"), undefined); assert.doesNotMatch(text, /\sstyle=/i); assert.match(text, /^<!doctype html>/); assert.match(text, /<h1 class="manuscript-title">Warden & <Silence> 雪<\/h1>/); assert.match(text, /<h2 class="manuscript-matter-heading">[^<]+<\/h2>/); assert.match(text, /<h1 class="manuscript-part-heading">Part One — The Silence Breaks<\/h1>/); assert.match(text, /<h2 class="manuscript-chapter-heading">Chapter One — The Silence of Östersund<\/h2>/); for (const [tag, className] of [["h1", "manuscript-title"], ["h2", "manuscript-matter-heading"], ["h1", "manuscript-part-heading"], ["h2", "manuscript-chapter-heading"]]) assert.equal(effectiveFontWeight(css, tag, className), "700", className); assert.deepEqual(genericHeadingWeightSelectors(css), []); assert.doesNotMatch(css, /font-weight:(?:normal|400)|(?:^|[;}])font:/); assert.match(text, /<p class="first-paragraph">Östersund was silent/); assert.match(text, /<div class="scene-break" role="separator">#<\/div><h3 class="manuscript-body-heading">Scene 2[^<]*<\/h3><p class="first-paragraph">The barn door opened/); assert.doesNotMatch(text, /<p class="(?:first-paragraph|body-text)"><strong>Östersund/); const separateText = strFromU8((await separateHeadingOutput("html")).bytes); const separateCss = /<style>([\s\S]+)<\/style>/.exec(separateText)?.[1] ?? ""; for (const [tag, className, expected] of [["h1", "manuscript-part-number", /<h1 class="manuscript-part-number">Part One<\/h1>/], ["h1", "manuscript-part-title", /<h1 class="manuscript-part-title">The Silence Breaks<\/h1>/], ["h2", "manuscript-chapter-number", /<h2 class="manuscript-chapter-number">Chapter One<\/h2>/], ["h2", "manuscript-chapter-title", /<h2 class="manuscript-chapter-title">The Silence of Östersund<\/h2>/]] as const) { assert.match(separateText, expected); assert.equal(effectiveFontWeight(separateCss, tag, className), "700", className); } assert.doesNotMatch(text, /<script\b|(?:src|href)=["']https?:/i); assert.doesNotMatch(text, /Revision Notes|Internal synopsis/); });
|
||||
|
||||
test("Markdown is publication-ready UTF-8 with semantic structure, matter, scenes, and canonical spacing", "markdown", async () => {
|
||||
const value = await output("markdown"); const text = strFromU8(value.bytes);
|
||||
assert.equal(value.mimeType, "text/markdown;charset=utf-8"); assert.equal(value.filename, "Warden.md");
|
||||
assert.match(text, /^# Warden & <Silence> 雪\n\nA & B 'Writer'/); assert.match(text, /^# (?:Copyright|Title Page)$/m); assert.match(text, /^# Part One — The Silence Breaks$/m); assert.match(text, /^## Chapter One — The Silence of Östersund$/m); assert.match(text, /^### Scene 1 - Opening$/m); assert.match(text, /^# Acknowledgements$/m);
|
||||
assert.match(text, /Östersund/); assert.match(text, /“Listen,”/); assert.match(text, /^#$/m); assert.doesNotMatch(text, /---|Synopsis|Revision Notes|Dashboard|Internal synopsis|Part (?:0|Zero)|Chapter (?:0|Zero)/i);
|
||||
assert.doesNotMatch(text, /^#{1,6}\s+\*\*/m); assert.doesNotMatch(text, /\*\*Östersund was silent/);
|
||||
const indentationEnabled = await outputWithIndent("markdown", true); const indentationDisabled = await outputWithIndent("markdown", false); assert.deepEqual(indentationEnabled.bytes, indentationDisabled.bytes); assert.deepEqual(value.bytes, indentationDisabled.bytes);
|
||||
assert.doesNotMatch(text, /^(?: {4}|\t)\S/m); assert.doesNotMatch(text, /<(?:p|style)\b| /i);
|
||||
assert.doesNotMatch(text, /\n{3,}/); assert.match(text, /\n$/); assert.doesNotMatch(text, /\n\n$/);
|
||||
const second = await EXPORTERS.markdown.generate({ session, document, options, filename: "Warden.md" }); assert.deepEqual(value.bytes, second.bytes);
|
||||
assert.equal(EXPORT_VALIDATORS.markdown.validate(value.bytes, { session, document, options, filename: value.filename }).valid, true);
|
||||
});
|
||||
|
||||
test("PDF word wrapping uses full width after the first indented line and cannot hang on long tokens", "pdf", () => {
|
||||
const source = "The Silence of Östersund carried across the valley while a much wider sentence — with punctuation — continued toward the right margin without losing or duplicating words."; const usable = pdfPageDimensions("a4").width - 144; const wrapped = wrapNativePdfText(source, "Times-Roman", 12, usable - 36, usable); assert.equal(wrapped.join(" "), source); assert.ok(wrapped.length < wrapNativePdfText(source, "Times-Roman", 12, 260, 260).length); assert.ok(measureNativePdfText(wrapped[0], "Times-Roman", 12) <= usable - 36 + 0.01); wrapped.slice(1).forEach((line) => assert.ok(measureNativePdfText(line, "Times-Roman", 12) <= usable + 0.01)); const token = "W".repeat(500); const tokenLines = wrapNativePdfText(token, "Times-Roman", 12, 100); assert.ok(tokenLines.length > 1); assert.equal(tokenLines.join(""), token);
|
||||
test("Markdown preserves bold, italics, readable links, Unicode, Swedish characters, and omits zero headings", "markdown", () => {
|
||||
const fixture: SemanticDocument = { title: "Vinterljus", author: "Åsa Öberg", language: "sv", wordCount: 20, sections: [
|
||||
{ id: "title", kind: "title", title: "Vinterljus", blocks: [{ kind: "heading", style: "title", inlines: [{ text: "Vinterljus" }] }, { kind: "heading", style: "author", inlines: [{ text: "Åsa Öberg" }] }] },
|
||||
{ id: "front", kind: "front-matter", title: "Förord", blocks: [{ kind: "heading", style: "front-matter", inlines: [{ text: "Förord" }] }, { kind: "paragraph", first: true, inlines: inlineMarkdown("**Fet**, *kursiv* och [läsbar länk](https://example.com/ö).") }] },
|
||||
{ id: "part-zero", kind: "part", title: "Part 0", number: 0, blocks: [{ kind: "heading", style: "part-number", inlines: [{ text: "Part 0" }] }] },
|
||||
{ id: "chapter-zero", kind: "chapter", title: "Chapter 0", number: 0, blocks: [{ kind: "heading", style: "chapter-number", inlines: [{ text: "Chapter 0" }] }, { kind: "paragraph", first: true, inlines: [{ text: "Å Ä Ö å ä ö — “smart quotes” 雪" }] }] },
|
||||
{ id: "back", kind: "back-matter", title: "Om författaren", blocks: [{ kind: "heading", style: "back-matter", inlines: [{ text: "Om författaren" }] }, { kind: "paragraph", first: true, inlines: [{ text: "Slut." }] }] }
|
||||
] };
|
||||
const text = renderSemanticMarkdown(fixture); assert.match(text, /\*\*Fet\*\*/); assert.match(text, /\*kursiv\*/); assert.match(text, /\[läsbar länk\]\(https:\/\/example\.com\/ö\)/); assert.match(text, /Å Ä Ö å ä ö — “smart quotes” 雪/); assert.doesNotMatch(text, /Part 0|Chapter 0/); assert.match(text, /^# Förord$/m); assert.match(text, /^# Om författaren$/m); assert.equal(new TextDecoder("utf-8", { fatal: true }).decode(new TextEncoder().encode(text)), text);
|
||||
});
|
||||
|
||||
test("PDF block layout applies margins once, preserves indentation, spaces headings, and keeps text in bounds", "pdf", () => {
|
||||
const repeated = Array.from({ length: 150 }, () => "Östersund remained quiet while the long manuscript paragraph flowed naturally across the full readable page width.").join(" ");
|
||||
const fixture = { title: "Warden of Silence", author: "A Writer", language: "sv", wordCount: 2_000, sections: [{ id: "layout", kind: "body", title: "Layout", blocks: [
|
||||
{ kind: "heading", style: "title", inlines: [{ text: "Warden of Silence" }] }, { kind: "heading", style: "author", inlines: [{ text: "A Writer" }], pageBreakAfter: true },
|
||||
{ kind: "heading", style: "front-matter", inlines: [{ text: "Copyright" }], pageBreakBefore: true }, { kind: "paragraph", inlines: [{ text: "Front matter text." }], first: true },
|
||||
{ kind: "heading", style: "part-number", inlines: [{ text: "Part One" }], pageBreakBefore: true }, { kind: "heading", style: "part-title", inlines: [{ text: "Arrival" }] },
|
||||
{ kind: "heading", style: "chapter-number", inlines: [{ text: "Chapter One" }], pageBreakBefore: true }, { kind: "heading", style: "chapter-title", inlines: [{ text: "The Silence of Östersund" }] },
|
||||
{ kind: "paragraph", inlines: [{ text: "First prose after the Chapter heading stays unindented and readable." }], first: true }, { kind: "paragraph", inlines: [{ text: repeated }], first: false },
|
||||
{ kind: "scene-break", text: "#" }, { kind: "paragraph", inlines: [{ text: "First prose after the scene break is unindented." }], first: true }, { kind: "paragraph", inlines: [{ text: "Later prose is indented once and wraps back to the normal left margin when needed because this sentence is deliberately long enough to wrap." }], first: false },
|
||||
{ kind: "heading", style: "chapter-number", inlines: [{ text: "Chapter Two" }], pageBreakBefore: true }, { kind: "heading", style: "chapter-title", inlines: [{ text: "The Weight of Knowing" }] }, { kind: "paragraph", inlines: [{ text: repeated }], first: true },
|
||||
{ kind: "heading", style: "back-matter", inlines: [{ text: "Acknowledgements" }], pageBreakBefore: true }, { kind: "paragraph", inlines: [{ text: "Back matter text." }], first: true }
|
||||
] }] } as const;
|
||||
const result = createNativePdf(fixture as never, options); const { layout } = result; assert.ok(Math.abs(layout.pageWidth - 595.28) < 0.01); assert.ok(Math.abs(layout.pageHeight - 841.89) < 0.01); assert.ok(Math.abs(layout.leftMargin - 72) < 0.01); assert.ok(Math.abs(layout.usableWidth - 451.28) < 0.02); assert.equal(EXPORT_VALIDATORS.pdf.validate(result.bytes).valid, true); assert.ok(layout.pages.length > 4);
|
||||
const located = layout.pages.flatMap((page, pageIndex) => page.lines.map((line) => ({ ...line, pageIndex }))); const chapterNumber = located.find((line) => line.text === "Chapter One")!; const chapterTitle = located.find((line) => line.text === "The Silence of Östersund")!; const firstProse = located.find((line) => line.text.startsWith("First prose after the Chapter"))!; assert.equal(chapterNumber.pageIndex, chapterTitle.pageIndex); assert.equal(chapterTitle.pageIndex, firstProse.pageIndex); assert.ok(chapterNumber.y - chapterTitle.y >= 29); assert.ok(chapterTitle.y - firstProse.y >= 52); assert.equal(firstProse.x, layout.leftMargin);
|
||||
const indented = located.find((line) => line.text.startsWith("Östersund remained"))!; assert.ok(Math.abs(indented.firstLineIndent - pdfPointsFromCm(0.75)) < 0.01); assert.ok(Math.abs(indented.x - (layout.leftMargin + pdfPointsFromCm(0.75))) < 0.01); const following = located.find((line) => line.role === "body" && line.pageIndex === indented.pageIndex && line.y < indented.y)!; assert.equal(following.x, layout.leftMargin);
|
||||
const scene = located.find((line) => line.role === "scene-break")!; assert.ok(Math.abs(scene.x - (layout.leftMargin + (layout.usableWidth - scene.width) / 2)) < 0.01); const scenePage = layout.pages[scene.pageIndex].lines; assert.notEqual(scenePage.at(-1)?.role, "scene-break"); const afterScene = located.find((line) => line.text.startsWith("First prose after the scene"))!; assert.equal(afterScene.x, layout.leftMargin);
|
||||
for (const line of located) { assert.ok(line.x >= layout.leftMargin - 0.01); assert.ok(line.x + line.width <= layout.pageWidth - layout.rightMargin + 0.01); assert.ok(line.y >= layout.bottomMargin - 0.01); assert.ok(line.y <= layout.pageHeight - layout.topMargin + 0.01); assert.ok(line.leading > 0); }
|
||||
test("Markdown downloads once without a vault write and history records markdown", "markdown", async () => {
|
||||
let downloads = 0; let writes = 0; let mimeType = ""; const vault = { ...loaded.vault, create: async () => { writes += 1; }, modify: async () => { writes += 1; } }; const settings = { ...DEFAULT_SETTINGS, profiles: [profile], exportHistory: [], compileLogs: [], defaultDownloadFormat: "markdown" as const }; const history = new CompileHistoryService(() => settings, async () => undefined, "0.9.2");
|
||||
const coordinator = new ExportCoordinator({ vault } as never, () => settings, async () => undefined, new OperationStateController(), history, { download: async (request: { filename: string; mimeType: string }) => { downloads += 1; mimeType = request.mimeType; return { started: true, filename: request.filename }; } } as never);
|
||||
const result = await coordinator.exportPreparedSession(session, { format: "markdown", filename: "folder/Warden.docx", showResult: false }); assert.equal(result.status, "success", result.error); assert.equal(downloads, 1); assert.equal(writes, 0); assert.equal(mimeType, "text/markdown;charset=utf-8"); assert.equal(settings.exportHistory[0].format, "markdown"); assert.deepEqual(settings.exportHistory[0].outputFiles, ["Warden.md"]);
|
||||
});
|
||||
|
||||
test("PDF regression fixture round-trips European text, syntax characters, wrapping, and pages", "pdf", () => {
|
||||
const fixtureText = `“Quoted text” – with an en dash — and an em dash. Å Ä Ö å ä ö Café, naïve, façade, déjà vu. English apostrophe: don’t Symbols: © ® ™ € £ ¥ Parentheses: (test) Backslash: C:\\Books\\Test Ampersand: A & B Angle brackets: <chapter> Combining: Cafe\u0301.`;
|
||||
const longText = Array.from({ length: 90 }, () => fixtureText).join(" ");
|
||||
const fixture = { title: "Warden of Silence", author: "A Writer", language: "sv", wordCount: 1_000, sections: [
|
||||
{ id: "title", kind: "title", title: "Warden of Silence", blocks: [{ kind: "heading", style: "title", inlines: [{ text: "Warden of Silence" }] }, { kind: "heading", style: "author", inlines: [{ text: "A Writer" }] }, { kind: "page-break" }] },
|
||||
{ id: "part", kind: "part", title: "Part One", blocks: [{ kind: "heading", style: "part-title", inlines: [{ text: "Part One" }], pageBreakBefore: true }] },
|
||||
{ id: "chapter", kind: "chapter", title: "The Silence of Östersund", blocks: [{ kind: "heading", style: "chapter-title", inlines: [{ text: "The Silence of Östersund" }], pageBreakBefore: true }, { kind: "paragraph", inlines: [{ text: fixtureText }], first: true }, { kind: "scene-break", text: "#" }, { kind: "paragraph", inlines: [{ text: longText }], first: false }, { kind: "page-break" }, { kind: "paragraph", inlines: [{ text: "After the page break." }], first: true }] }
|
||||
] } as const;
|
||||
const generatedPdf = createNativePdf(fixture as never, options); const source = new TextDecoder("latin1").decode(generatedPdf.bytes); const recovered = recoverNativePdfText(generatedPdf.bytes);
|
||||
assert.equal(EXPORT_VALIDATORS.pdf.validate(generatedPdf.bytes).valid, true); assert.match(source, /<93> <201C>/); assert.match(source, /<94> <201D>/); assert.match(source, /<96> <2013>/); assert.match(source, /<97> <2014>/); assert.match(source, /<80> <20AC>/); assert.match(recovered, /Warden of Silence/); assert.match(recovered, /The Silence of Östersund/); assert.match(recovered, /“Quoted text” – with an en dash — and an em dash\./); assert.match(recovered, /Å Ä Ö å ä ö/); assert.match(recovered, /Café, naïve, façade, déjà vu/); assert.match(recovered, /don’t/); assert.match(recovered, /© ® ™ € £ ¥/); assert.match(recovered, /\(test\)/); assert.match(recovered, /C:\\Books\\Test/); assert.match(recovered, /A & B/); assert.match(recovered, /<chapter>/); assert.match(recovered, /Combining: Café/); assert.match(recovered, /After the page break/); assert.ok((source.match(/\/Type \/Page\b/g) ?? []).length > 1); assert.doesNotMatch(recovered, /Ã.|â€|©/); assert.doesNotMatch(source, /Revision Notes|Synopsis|Internal synopsis|---\s*\n/);
|
||||
assert.equal(generatedPdf.warnings.length, 0);
|
||||
test("Markdown validator rejects invalid UTF-8, YAML, authoring notes, dashboards, zero headings, and missing final newline", "markdown", () => {
|
||||
assert.equal(EXPORT_VALIDATORS.markdown.validate(new Uint8Array([0xc3, 0x28])).valid, false);
|
||||
for (const text of ["---\ntitle: Secret\n---\nBody\n", "# Synopsis\n\nBody\n", "# Revision Notes\n\nBody\n", "# Project Dashboard\n\nBody\n", "# Part 0\n\nBody\n", "## Chapter Zero\n\nBody\n", "# Title"]) assert.equal(EXPORT_VALIDATORS.markdown.validate(new TextEncoder().encode(text)).valid, false, text);
|
||||
});
|
||||
|
||||
test("PDF unsupported glyphs use one grouped informational fallback", "pdf", () => {
|
||||
const unsupported = { title: "Fallback", author: "", language: "en", wordCount: 2, sections: [{ id: "one", kind: "body", title: "Body", blocks: [{ kind: "paragraph", inlines: [{ text: "Snow 雪 and snow 雪 remain readable." }], first: true }] }] } as const;
|
||||
const generatedPdf = createNativePdf(unsupported as never, options); assert.equal(generatedPdf.warnings.length, 1); assert.equal(generatedPdf.warnings[0].severity, "information"); assert.equal(generatedPdf.warnings[0].code, "pdf-unsupported-glyphs"); assert.match(recoverNativePdfText(generatedPdf.bytes), /Snow \? and snow \? remain readable/); assert.equal(EXPORT_VALIDATORS.pdf.validate(generatedPdf.bytes).valid, true);
|
||||
});
|
||||
|
||||
test("PDF validator rejects the former identity-CID corruption strategy", "pdf", () => {
|
||||
const validPdf = createNativePdf({ title: "Test", author: "", language: "en", wordCount: 1, sections: [{ id: "one", kind: "body", title: "Body", blocks: [{ kind: "paragraph", inlines: [{ text: "Östersund" }], first: true }] }] } as never, options).bytes;
|
||||
const corrupted = new TextEncoder().encode(new TextDecoder("latin1").decode(validPdf).replace("/Encoding /WinAnsiEncoding", "/Encoding /Identity-H").replace("/Subtype /Type1", "/Subtype /Type0 /CIDToGIDMap /Identity")); assert.equal(EXPORT_VALIDATORS.pdf.validate(corrupted).valid, false);
|
||||
assert.equal(escapePdfLiteral("(test) C:\\Books\nNext\tLine"), "\\(test\\) C:\\\\Books\\nNext\\tLine");
|
||||
});
|
||||
|
||||
test("EPUB 3 has native container, package, navigation, ordered XHTML, no scripts or remote resources", "epub", async () => { const value = await output("epub"); const files = unzipSync(value.bytes); for (const name of ["mimetype", "META-INF/container.xml", "OEBPS/content.opf", "OEBPS/nav.xhtml", "OEBPS/style.css"]) assert.ok(files[name]); const all = Object.entries(files).filter(([name]) => name.endsWith(".xhtml")).map(([, bytes]) => strFromU8(bytes)).join("\n"); assert.match(all, /Part One/); assert.doesNotMatch(all, /<script\b|(?:src|href)=["']https?:/i); assert.doesNotMatch(all, /Revision Notes|Internal synopsis/); });
|
||||
|
||||
test("HTML is standalone, semantic, escaped, offline, and script-free", "html", async () => { const text = strFromU8((await output("html")).bytes); assert.match(text, /^<!doctype html>/); assert.match(text, /<style>[\s\S]+<main>/); assert.match(text, /class="part"/); assert.match(text, /Warden & <Silence> 雪/); assert.doesNotMatch(text, /<script\b|(?:src|href)=["']https?:/i); assert.doesNotMatch(text, /Revision Notes|Internal synopsis/); });
|
||||
|
||||
test("XML is deterministic, versioned, hierarchical, escaped, and excludes compiler state", "xml", async () => { const first = await output("xml"); const second = await EXPORTERS.xml.generate({ session, document, options, filename: "Warden.xml" }); assert.deepEqual(first.bytes, second.bytes); const text = strFromU8(first.bytes); assert.match(text, /xmlns="https:\/\/manuscript-compiler\.dev\/schema" schemaVersion="1\.0"/); assert.match(text, /<frontMatter>[\s\S]*<body>[\s\S]*<part[\s\S]*<chapter[\s\S]*<scene order="1">[\s\S]*<backMatter>/); assert.match(text, /Warden & <Silence> 雪/); assert.doesNotMatch(text, /profileId|vaultPath|filePath|<settings>|Revision Notes|Internal synopsis/); });
|
||||
test("XML is deterministic, semantic, presentation-free, escaped, and excludes compiler state", "xml", async () => { const first = await output("xml"); const second = await EXPORTERS.xml.generate({ session, document, options, filename: "Warden.xml" }); assert.deepEqual(first.bytes, second.bytes); assert.deepEqual((await outputWithIndent("xml", true)).bytes, (await outputWithIndent("xml", false)).bytes); const text = strFromU8(first.bytes); assert.match(text, /xmlns="https:\/\/manuscript-compiler\.dev\/schema" schemaVersion="1\.0"/); assert.match(text, /<frontMatter>[\s\S]*<body>[\s\S]*<part[\s\S]*<chapter[\s\S]*<scene order="1">[\s\S]*<backMatter>/); assert.match(text, /<heading type="(?:title|author|front-matter|part-number|part-title|chapter-number|chapter-title|back-matter)">/); assert.doesNotMatch(text, /<heading[^>]*(?:bold|style|font-weight)|<heading[^>]*><(?:strong|b|span)/); assert.doesNotMatch(text, /<paragraph[^>]*(?:style|indent)|<paragraph[^>]*>[ \t]/); assert.match(text, /<paragraph first="true">Östersund was silent/); assert.match(text, /Warden & <Silence> 雪/); assert.doesNotMatch(text, /profileId|vaultPath|filePath|<settings>|Revision Notes|Internal synopsis/); });
|
||||
|
||||
let passed = 0;
|
||||
for (const [name, format, action] of tests) { if (selected && format !== "common" && format !== selected) continue; try { await action(); passed += 1; process.stdout.write(`✓ ${name}\n`); } catch (error) { process.stderr.write(`✗ ${name}\n`); throw error; } }
|
||||
process.stdout.write(`${passed} export tests passed${selected ? ` for ${selected.toUpperCase()}` : ""}.\n`);
|
||||
if (!selected) { const artifactDirectory = ".test-build/export-artifacts"; await mkdir(artifactDirectory, { recursive: true }); for (const format of ["odt", "epub", "html", "markdown", "xml"] as const) { const value = await output(format); await writeFile(`${artifactDirectory}/${value.filename}`, value.bytes); } process.stdout.write(`Wrote non-DOCX inspection artefacts to ${artifactDirectory}.\n`); }
|
||||
|
|
|
|||
|
|
@ -38,10 +38,10 @@ const statisticsMs = performance.now() - statisticsStarted;
|
|||
const markdownStarted = performance.now();
|
||||
const markdown = new MarkdownGenerator().generate(book, profile, statistics, new Date("2026-01-01T00:00:00Z"));
|
||||
const markdownMs = performance.now() - markdownStarted;
|
||||
const options = { title: "Benchmark", author: "", language: "en", titlePage: false, tableOfContents: false, font: "Times New Roman", fontSize: 12, lineSpacing: 1.5, firstLineIndentCm: 0.75, pageSize: "a4" as const, pageMarginCm: 2.54, chapterPageBreak: true, sceneSeparator: "#" };
|
||||
const options = { title: "Benchmark", author: "", language: "en", titlePage: false, tableOfContents: false, font: "Times New Roman", fontSize: 12, lineSpacing: 1.5, indentParagraphs: true, firstLineIndentCm: 0.75, pageSize: "a4" as const, chapterPageBreak: true, sceneSeparator: "#" };
|
||||
const semanticStarted = performance.now(); const document = createSemanticDocument(book, profile, options, statistics.totalWordCount); const semanticMs = performance.now() - semanticStarted;
|
||||
const session = { book, profile, statistics } as never; const formatResults: Array<{ format: string; ms: number; bytes: number }> = [];
|
||||
for (const format of EXPORT_FORMATS) { const started = performance.now(); const generated = await EXPORTERS[format].generate({ session, document, options, filename: `Benchmark.${format}` }); const ms = performance.now() - started; const validation = EXPORT_VALIDATORS[format].validate(generated.bytes); if (!validation.valid) throw new Error(`${format.toUpperCase()} benchmark validation failed: ${validation.errors.join(" ")}`); formatResults.push({ format, ms, bytes: generated.bytes.length }); }
|
||||
const session = { book, profile, statistics } as never; const formatResults: Array<{ format: string; indentation: boolean; ms: number; bytes: number }> = [];
|
||||
for (const indentation of [true, false]) for (const format of EXPORT_FORMATS) { const started = performance.now(); const generated = await EXPORTERS[format].generate({ session, document, options: { ...options, indentParagraphs: indentation }, filename: `Benchmark.${format}` }); const ms = performance.now() - started; const validation = EXPORT_VALIDATORS[format].validate(generated.bytes); if (!validation.valid) throw new Error(`${format.toUpperCase()} benchmark validation failed: ${validation.errors.join(" ")}`); formatResults.push({ format, indentation, ms, bytes: generated.bytes.length }); }
|
||||
const duration = parseMs + statisticsMs + markdownMs + semanticMs + formatResults.reduce((total, item) => total + item.ms, 0); const memory = typeof process.memoryUsage === "function" ? Math.round(process.memoryUsage().heapUsed / 1024 / 1024) : undefined;
|
||||
if (duration > 120_000) throw new Error(`Large-manuscript benchmark exceeded the 120-second runaway guard (${Math.round(duration)} ms).`);
|
||||
process.stdout.write(`Large-manuscript benchmark: ${statistics.chapterCount} chapters, ${statistics.sceneCount} scenes, ${statistics.totalWordCount.toLocaleString()} words in ${Math.round(duration)} ms. Preparation once: parse/clean/Book ${Math.round(parseMs)} ms; statistics ${Math.round(statisticsMs)} ms; Markdown ${Math.round(markdownMs)} ms; semantic projection ${Math.round(semanticMs)} ms. Exports: ${formatResults.map((item) => `${item.format.toUpperCase()} ${Math.round(item.ms)} ms/${item.bytes.toLocaleString()} bytes`).join("; ")}.${memory === undefined ? "" : ` Observed heap after generation: ${memory} MiB.`}\n`);
|
||||
process.stdout.write(`Large-manuscript benchmark: ${statistics.chapterCount} chapters, ${statistics.sceneCount} scenes, ${statistics.totalWordCount.toLocaleString()} words in ${Math.round(duration)} ms. Preparation once: parse/clean/Book ${Math.round(parseMs)} ms; statistics ${Math.round(statisticsMs)} ms; Markdown ${Math.round(markdownMs)} ms; semantic projection ${Math.round(semanticMs)} ms. Exports from the same Book: ${formatResults.map((item) => `${item.format.toUpperCase()} indent ${item.indentation ? "on" : "off"} ${Math.round(item.ms)} ms/${item.bytes.toLocaleString()} bytes`).join("; ")}.${memory === undefined ? "" : ` Observed heap after generation: ${memory} MiB.`}\n`);
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ export class TextAreaComponent { inputEl = element(); constructor(_container?: u
|
|||
export const apiVersion = "1.13.1";
|
||||
export const Platform = { isDesktopApp: true, isMobile: false };
|
||||
export const normalizePath = (value: string): string => value;
|
||||
export const setIcon = (_element: HTMLElement, _icon: string): void => undefined;
|
||||
|
|
|
|||
57
tests/run.ts
57
tests/run.ts
|
|
@ -43,7 +43,7 @@ import { buildExportPreviewViewModel } from "../src/workspace/export-preview";
|
|||
import { isEffectivelyIncluded, moveSibling, setItemIncluded } from "../src/workspace/content-tree";
|
||||
import { changedRowPaths, snapshotRows } from "../src/workspace/contents-step";
|
||||
import { ContentsTreeViewState } from "../src/workspace/contents-tree-view-state";
|
||||
import { formatAfterKey } from "../src/workspace/create-docx-step";
|
||||
import { formatAfterKey, supportsParagraphIndentation } from "../src/workspace/create-docx-step";
|
||||
import { OperationStateController } from "../src/operation-state";
|
||||
import { CompileHistoryService } from "../src/compile-history";
|
||||
import { centimetresToInches, centimetresToTwips, clampCentimetres, inchesToCentimetres, twipsToCentimetres } from "../src/measurements";
|
||||
|
|
@ -57,7 +57,7 @@ const test = (name: string, action: () => void | Promise<void>): void => { tests
|
|||
const execFileAsync = promisify(execFile);
|
||||
async function sourceFiles(folder: string): Promise<string[]> { const entries = await readdir(folder, { withFileTypes: true }); const nested = await Promise.all(entries.map((entry) => entry.isDirectory() ? sourceFiles(path.join(folder, entry.name)) : Promise.resolve(entry.name.endsWith(".ts") ? [path.join(folder, entry.name)] : []))); return nested.flat(); }
|
||||
const file = (name: string, content: string, metadata: Record<string, unknown> = {}): ManuscriptDocument => ({ file: { name: `${name}.md`, basename: name, path: `Book/${name}.md` } as never, title: name, number: extractNumber(name), metadata: { values: metadata }, content, excluded: false });
|
||||
const preparedRequest = (root: string, plan: ReturnType<typeof createContentPlan>): SimpleCompileRequest => ({ manuscriptRoot: root, structurePreset: "novel-parts", includeFrontMatter: true, includeBackMatter: true, exportFolder: "", outputFilename: "Warden of Silence.docx", outputFormat: "docx", docxPreset: "vellum", contentPlan: plan, formatting: { font: "Times New Roman", fontSize: 12, lineSpacing: 2, firstLineIndentCm: 1.27, pageSize: "a4", chapterPageBreak: true, titlePage: true }, tableOfContents: false, partDisplay: "word-title", chapterDisplay: "word-title", custom: { variables: { BookTitle: "Warden of Silence", Author: "Anthony Fitzpatrick" }, bodySectionAliases: ["Scene", "Manuscript", "Text", "Draft", "Body"] } });
|
||||
const preparedRequest = (root: string, plan: ReturnType<typeof createContentPlan>): SimpleCompileRequest => ({ manuscriptRoot: root, structurePreset: "novel-parts", includeFrontMatter: true, includeBackMatter: true, exportFolder: "", outputFilename: "Warden of Silence.docx", outputFormat: "docx", docxPreset: "vellum", contentPlan: plan, formatting: { font: "Times New Roman", fontSize: 12, lineSpacing: 2, indentParagraphs: true, firstLineIndentCm: 1.27, pageSize: "a4", chapterPageBreak: true, titlePage: true }, tableOfContents: false, partDisplay: "word-title", chapterDisplay: "word-title", custom: { variables: { BookTitle: "Warden of Silence", Author: "Anthony Fitzpatrick" }, bodySectionAliases: ["Scene", "Manuscript", "Text", "Draft", "Body"] } });
|
||||
|
||||
test("template engine resolves known and unknown variables", () => assert.equal(new TemplateEngine().render("{BookTitle} — {Unknown}", { BookTitle: "North" }), "North —"));
|
||||
test("internal links retain visible text", () => assert.equal(stripInternalLinks("[[People/Elin|Elin Andersson]] met [[Noah]]."), "Elin Andersson met Noah."));
|
||||
|
|
@ -78,23 +78,24 @@ test("parser supports chapter notes in manuscripts without Parts", async () => {
|
|||
test("bounded concurrency processes every item", async () => { const seen: number[] = []; await mapConcurrent([1, 2, 3, 4], 2, async (value) => { seen.push(value); }); assert.deepEqual(seen.sort(), [1, 2, 3, 4]); });
|
||||
test("bounded parsing cancels without processing the remaining queue", async () => { const controller = new AbortController(); const seen: number[] = []; await assert.rejects(mapConcurrent([1, 2, 3, 4], 1, async (value) => { seen.push(value); controller.abort(); }, controller.signal), CompilationCancelledError); assert.deepEqual(seen, [1]); });
|
||||
test("statistics and Markdown generation are deterministic", () => { const scene = file("Scene 1", "one two three"); const profile = createDefaultProfiles()[0]; const book = { root: { path: "Book", name: "Book" }, title: "Book", frontMatter: { kind: "front", title: "Front Matter", documents: [] }, parts: [{ title: "Part 1", name: "Part 1", number: 1, path: "Book/Part 1", chapters: [{ title: "Chapter 1", name: "Chapter 1", number: 1, path: "Book/Part 1/Chapter 1", scenes: [scene], orphan: false }], orphanScenes: [] }], orphanScenes: [], backMatter: { kind: "back", title: "Back Matter", documents: [] }, includedFiles: [scene.file], excludedFiles: [], warnings: [], issues: [] } as Book; const stats = new StatisticsEngine().calculate(book, profile, 250); assert.equal(stats.totalWordCount, 3); const generator = new MarkdownGenerator(); const date = new Date("2026-01-02T00:00:00Z"); assert.equal(generator.generate(book, profile, stats, date), generator.generate(book, profile, stats, date)); assert.match(generator.generate(book, profile, stats, date), /^# Part 1/); const issues = new WarningEngine().analyze(book, profile, "Exports/Book.md"); assert.ok(issues.some((issue) => issue.code === "missing-front-matter")); });
|
||||
test("download filenames repair reserved names, strip paths, and correct extensions", () => { assert.equal(exportFilename("Östersund 雪.docx", "docx"), "Östersund 雪.docx"); assert.equal(exportFilename("CON.docx", "docx"), "_CON.docx"); assert.equal(exportFilename("Bad\u0001Name. .docx", "docx"), "Bad-Name.docx"); assert.equal(exportFilename("folder/book.docx", "pdf"), "book.pdf"); assert.equal(exportFilename("book.HTML", "html"), "book.html"); });
|
||||
test("download filenames repair reserved names, strip paths, and correct extensions", () => { assert.equal(exportFilename("Östersund 雪.docx", "docx"), "Östersund 雪.docx"); assert.equal(exportFilename("CON.docx", "docx"), "_CON.docx"); assert.equal(exportFilename("Bad\u0001Name. .docx", "docx"), "Bad-Name.docx"); assert.equal(exportFilename("folder/book.docx", "markdown"), "book.md"); assert.equal(exportFilename("book.HTML", "html"), "book.html"); });
|
||||
test("invalid configuration blocks every format while structural findings remain reviewable", () => { assert.equal(canProceedWithExport([{ severity: "error", code: "invalid-profile", message: "unsafe" }]), false); assert.equal(canProceedWithExport([{ severity: "error", code: "structure", message: "review" }]), true); assert.equal(canProceedWithExport([]), true); });
|
||||
test("native DOCX defaults require no desktop or Pandoc state", () => { assert.equal(DEFAULT_SETTINGS.defaultExportFormat, "docx"); assert.equal(DEFAULT_SETTINGS.defaultDocxStyle, "vellum"); assert.equal(DEFAULT_SETTINGS.automaticallyDetectPandoc, false); });
|
||||
for (const preset of ["novel-parts", "novel", "chapter-notes", "short-story", "anthology", "custom"] as const) test(`simple workflow resolves ${preset}`, () => { const base = createDefaultProfiles()[0]; const profile = resolveSimpleCompileRequest({ manuscriptRoot: "Books/Test", structurePreset: preset, includeFrontMatter: true, includeBackMatter: false, exportFolder: "Exports", outputFilename: "Test.docx", outputFormat: "docx", docxPreset: "vellum", custom: { useParts: false, chapterSource: "folders" } }, base); assert.equal(profile.manuscriptRoot, "Books/Test"); assert.equal(profile.exportFolder, "Exports"); assert.equal(profile.exportTarget, "docx"); assert.equal(profile.referenceDocx, ""); });
|
||||
test("Vellum preset applies safe cleaning and readable links", () => { const profile = resolveSimpleCompileRequest({ manuscriptRoot: "Book", structurePreset: "novel-parts", includeFrontMatter: true, includeBackMatter: true, exportFolder: "Exports", outputFilename: "Book.docx", outputFormat: "docx", docxPreset: "vellum" }, createDefaultProfiles()[0]); assert.equal(profile.removeDataviewBlocks, true); assert.equal(profile.removeCallouts, true); assert.equal(profile.stripInternalLinks, true); assert.equal(profile.generateTableOfContents, false); assert.equal(profile.sceneSeparator, "#"); assert.equal(profile.docxPageSize, "a4"); assert.equal(profile.docxFirstLineIndentCm, 0.75); });
|
||||
test("Vellum preset applies safe cleaning and readable links", () => { const profile = resolveSimpleCompileRequest({ manuscriptRoot: "Book", structurePreset: "novel-parts", includeFrontMatter: true, includeBackMatter: true, exportFolder: "Exports", outputFilename: "Book.docx", outputFormat: "docx", docxPreset: "vellum" }, createDefaultProfiles()[0]); assert.equal(profile.removeDataviewBlocks, true); assert.equal(profile.removeCallouts, true); assert.equal(profile.stripInternalLinks, true); assert.equal(profile.generateTableOfContents, false); assert.equal(profile.sceneSeparator, "#"); assert.equal(profile.docxPageSize, "a4"); assert.equal(profile.docxIndentParagraphs, true); assert.equal(profile.docxFirstLineIndentCm, 0.75); });
|
||||
test("TOC selection survives preset resolution as a real compile-request choice", () => { const profile = resolveSimpleCompileRequest({ manuscriptRoot: "Book", structurePreset: "novel-parts", includeFrontMatter: true, includeBackMatter: true, exportFolder: "Exports", outputFilename: "Book.docx", outputFormat: "docx", docxPreset: "vellum", tableOfContents: true }, createDefaultProfiles()[0]); assert.equal(profile.generateTableOfContents, true); });
|
||||
test("Standard DOCX preset uses conventional filename ordering", () => { const profile = resolveSimpleCompileRequest({ manuscriptRoot: "Book", structurePreset: "novel", includeFrontMatter: true, includeBackMatter: true, exportFolder: "Exports", outputFilename: "Book.docx", outputFormat: "docx", docxPreset: "standard" }, createDefaultProfiles()[0]); assert.equal(profile.orderingMethod, "filename"); assert.equal(profile.stripYamlFrontmatter, true); assert.equal(profile.sceneSeparator, "* * *"); assert.equal(profile.docxPageSize, "a4"); });
|
||||
test("Vellum, Standard, and Custom DOCX formatting resolve deterministically", () => {
|
||||
assert.deepEqual(docxFormattingForPreset("vellum"), DOCX_FORMATTING_PRESETS.vellum);
|
||||
assert.deepEqual(docxFormattingForPreset("standard"), DOCX_FORMATTING_PRESETS.standard);
|
||||
assert.deepEqual(docxFormattingForPreset("vellum"), docxFormattingForPreset("vellum"));
|
||||
const custom = { font: "Georgia", fontSize: 13, lineSpacing: 1.5, firstLineIndentCm: 0.75, pageSize: "a4" as const, chapterPageBreak: false, titlePage: true };
|
||||
const custom = { font: "Georgia", fontSize: 13, lineSpacing: 1.5, indentParagraphs: false, firstLineIndentCm: 0.75, pageSize: "a4" as const, chapterPageBreak: false, titlePage: true };
|
||||
assert.deepEqual(docxFormattingForPreset("custom", true, custom), custom);
|
||||
assert.equal(DOCX_FORMATTING_PRESETS.vellum.chapterPageBreak, true);
|
||||
assert.equal(DOCX_FORMATTING_PRESETS.standard.chapterPageBreak, true);
|
||||
assert.equal(DOCX_FORMATTING_PRESETS.standard.fontSize, 12);
|
||||
assert.equal(DOCX_FORMATTING_PRESETS.standard.lineSpacing, 2);
|
||||
assert.equal(DOCX_FORMATTING_PRESETS.vellum.indentParagraphs, true); assert.equal(DOCX_FORMATTING_PRESETS.standard.indentParagraphs, true);
|
||||
assert.equal(DOCX_FORMATTING_PRESETS.vellum.pageSize, "a4"); assert.equal(DOCX_FORMATTING_PRESETS.standard.pageSize, "a4");
|
||||
});
|
||||
|
||||
|
|
@ -154,7 +155,15 @@ test("0.9.1 settings migrate to 0.9.2 idempotently without losing author choices
|
|||
assert.deepEqual({ root: migrated.manuscriptRoot, folder: migrated.exportFolder, filename: migrated.outputFilename, variables: migrated.variables, filters: migrated.metadataFilters, font: migrated.docxFont, size: migrated.docxFontSize, spacing: migrated.docxLineSpacing, indentCm: migrated.docxFirstLineIndentCm, page: migrated.docxPageSize, chapterBreak: migrated.docxChapterPageBreak, titlePage: migrated.docxTitlePage, toc: migrated.generateTableOfContents }, { root: profile.manuscriptRoot, folder: profile.exportFolder, filename: profile.outputFilename, variables: profile.variables, filters: profile.metadataFilters, font: "Georgia", size: 13, spacing: 1.5, indentCm: 0.635, page: "a4", chapterBreak: false, titlePage: true, toc: true });
|
||||
assert.equal(twice.activeProfileId, profile.id); assert.equal(twice.defaultProfileId, profile.id); assert.equal(twice.exportHistory[0].id, "history-1"); assert.equal(twice.showAdvancedOptions, true);
|
||||
});
|
||||
test("first-run defaults are DOCX, Vellum, A4, metric, previewed, and overwrite-safe", () => { assert.equal(DEFAULT_SETTINGS.defaultExportFormat, "docx"); assert.equal(DEFAULT_SETTINGS.defaultDocxStyle, "vellum"); assert.equal(DEFAULT_SETTINGS.defaultDocxPageSize, "a4"); assert.equal(DEFAULT_SETTINGS.defaultDocxFirstLineIndentCm, 0.75); assert.equal(DEFAULT_SETTINGS.showPreview, true); assert.equal(DEFAULT_SETTINGS.warnBeforeOverwrite, true); assert.equal(DEFAULT_SETTINGS.showAdvancedOptions, false); const profiles = createDefaultProfiles(); assert.deepEqual(profiles.map((profile) => profile.docxPageSize), ["a4", "a4"]); assert.deepEqual(profiles.map((profile) => profile.docxFirstLineIndentCm), [1.27, 0.75]); assert.deepEqual(profiles.map((profile) => profile.sceneSeparator), ["#", "#"]); });
|
||||
test("first-run defaults are DOCX, Vellum, A4, metric, previewed, and overwrite-safe", () => { assert.equal(DEFAULT_SETTINGS.defaultExportFormat, "docx"); assert.equal(DEFAULT_SETTINGS.defaultDocxStyle, "vellum"); assert.equal(DEFAULT_SETTINGS.defaultDocxPageSize, "a4"); assert.equal(DEFAULT_SETTINGS.defaultIndentParagraphs, true); assert.equal(DEFAULT_SETTINGS.defaultDocxFirstLineIndentCm, 0.75); assert.equal(DEFAULT_SETTINGS.showPreview, true); assert.equal(DEFAULT_SETTINGS.warnBeforeOverwrite, true); assert.equal(DEFAULT_SETTINGS.showAdvancedOptions, false); const profiles = createDefaultProfiles(); assert.deepEqual(profiles.map((profile) => profile.docxPageSize), ["a4", "a4"]); assert.deepEqual(profiles.map((profile) => profile.docxIndentParagraphs), [true, true]); assert.deepEqual(profiles.map((profile) => profile.docxFirstLineIndentCm), [1.27, 0.75]); assert.deepEqual(profiles.map((profile) => profile.sceneSeparator), ["#", "#"]); });
|
||||
|
||||
test("paragraph indentation migration is idempotent and preserves existing indent values", () => {
|
||||
const profile = createDefaultProfiles()[1]; delete profile.docxIndentParagraphs; profile.docxFirstLineIndentCm = 0.635;
|
||||
const legacy = { ...DEFAULT_SETTINGS, profiles: [profile], activeProfileId: profile.id, defaultProfileId: profile.id } as Record<string, unknown>; delete legacy.defaultIndentParagraphs;
|
||||
const once = repairSettings(legacy as never); const snapshot = JSON.stringify(once); const twice = repairSettings(once);
|
||||
assert.equal(once.defaultIndentParagraphs, true); assert.equal(once.profiles[0].docxIndentParagraphs, true); assert.equal(once.profiles[0].docxFirstLineIndentCm, 0.635); assert.equal(JSON.stringify(twice), snapshot);
|
||||
const disabled = repairSettings({ ...once, defaultIndentParagraphs: false, profiles: [{ ...once.profiles[0], docxIndentParagraphs: false }] }); assert.equal(disabled.defaultIndentParagraphs, false); assert.equal(disabled.profiles[0].docxIndentParagraphs, false); assert.equal(disabled.profiles[0].docxFirstLineIndentCm, 0.635);
|
||||
});
|
||||
|
||||
test("legacy inch indentation and explicit Letter page size migrate once without drift", () => {
|
||||
const profile = createDefaultProfiles()[0]; delete profile.docxFirstLineIndentCm; profile.docxFirstLineIndent = 0.5; profile.docxPageSize = "letter";
|
||||
|
|
@ -207,7 +216,7 @@ test("guided preparation keeps explicit inclusion, role, and manual order author
|
|||
|
||||
test("book-root resolution and command identifiers are central and stable", async () => {
|
||||
const root = Object.assign(new TFolder(), { name: "Book", path: "Books/Book", children: [] }); const vault = { getAbstractFileByPath: (value: string) => value === root.path ? root : null } as never;
|
||||
assert.equal(new BookRootResolver(vault).require(root.path), root); assert.equal(new BookRootResolver(vault).selected(root), root); assert.deepEqual(Object.values(COMMAND_IDS), ["compile-manuscript", "compile-current-book", "compile-selected-folder", "validate-manuscript", "generate-diagnostics-report"]); const main = await readFile(path.join("src", "main.ts"), "utf8"); for (const title of ["Compile Manuscript", "Compile Current Book", "Compile Selected Folder", "Validate Manuscript", "Generate Diagnostics Report"]) assert.match(main, new RegExp(`name: "${title}"`)); assert.match(main, /workspace\.on\("file-menu"/); assert.match(main, /openCompilerForFolder\(folder/);
|
||||
assert.equal(new BookRootResolver(vault).require(root.path), root); assert.equal(new BookRootResolver(vault).selected(root), root); assert.deepEqual(Object.values(COMMAND_IDS), ["compile-manuscript", "compile-current-book", "compile-selected-folder", "validate-manuscript", "generate-diagnostics-report"]); const main = await readFile(path.join("src", "main.ts"), "utf8"); for (const title of ["Compile manuscript", "Compile current book", "Compile selected folder", "Validate manuscript", "Generate diagnostics report"]) assert.match(main, new RegExp(`name: "${title}"`)); assert.match(main, /workspace\.on\("file-menu"/); assert.match(main, /openCompilerForFolder\(folder/);
|
||||
});
|
||||
|
||||
test("File Explorer context action is folder-only and preserves the exact clicked root", () => {
|
||||
|
|
@ -300,8 +309,8 @@ test("workspace controller centralises every prepared-session invalidation rule"
|
|||
const controller = new CompileWorkspaceController(request, formatting, { prepare: async () => prepared, sessionIsCurrent: async () => true, export: async () => undefined }); controller.setDetectedPlan(loaded.root.path, plan);
|
||||
const invalidates = (mutation: () => void): void => { controller.state.preparedSession = prepared; mutation(); assert.equal(controller.state.preparedSession, undefined); };
|
||||
invalidates(() => controller.setRoot(`${loaded.root.path} changed`)); controller.setRoot(loaded.root.path); controller.setDetectedPlan(loaded.root.path, plan);
|
||||
invalidates(() => controller.setPreset("novel")); invalidates(() => controller.setRole(plan.find((item) => item.kind === "folder")!.path, "transparent")); invalidates(() => controller.setIncluded(plan.find((item) => item.kind === "note")!.path, false)); invalidates(() => controller.moveItem(plan.find((item) => item.kind === "note")!.path, 1)); invalidates(() => controller.setBodyAliases(["Body"])); invalidates(() => controller.setMatter("front", false)); invalidates(() => controller.setFormatting({ fontSize: 11 })); invalidates(() => controller.setTableOfContents(true)); invalidates(() => controller.setVariable("BookTitle", "Changed"));
|
||||
controller.state.preparedSession = prepared; controller.setDownloadFilename("Changed.pdf"); controller.setExportFormat("pdf"); assert.equal(controller.state.preparedSession, prepared);
|
||||
invalidates(() => controller.setPreset("novel")); invalidates(() => controller.setRole(plan.find((item) => item.kind === "folder")!.path, "transparent")); invalidates(() => controller.setIncluded(plan.find((item) => item.kind === "note")!.path, false)); invalidates(() => controller.moveItem(plan.find((item) => item.kind === "note")!.path, 1)); invalidates(() => controller.setBodyAliases(["Body"])); invalidates(() => controller.setMatter("front", false)); invalidates(() => controller.setFormatting({ fontSize: 11 })); const signatureBeforeIndentation = compileInputSignature(request, plan); invalidates(() => controller.setFormatting({ indentParagraphs: false })); assert.notEqual(compileInputSignature(request, plan), signatureBeforeIndentation); assert.equal(request.docxPreset, "custom"); invalidates(() => controller.setTableOfContents(true)); invalidates(() => controller.setVariable("BookTitle", "Changed"));
|
||||
controller.state.preparedSession = prepared; controller.setDownloadFilename("Changed.md"); controller.setExportFormat("markdown"); controller.setExportFormat("html"); assert.equal(controller.state.formatting.indentParagraphs, false); assert.equal(controller.state.preparedSession, prepared);
|
||||
});
|
||||
|
||||
test("guided workspace has exactly three stages and combines format choices with Create file", async () => {
|
||||
|
|
@ -370,7 +379,7 @@ test("advanced controls remain available and settings lead with Defaults", async
|
|||
const create = await readFile(path.join("src", "workspace", "create-docx-step.ts"), "utf8"); const settings = await readFile(path.join("src", "ui.ts"), "utf8");
|
||||
for (const label of ["Book title override", "Author override", "Font", "Font size", "Line spacing", "First-line indent (cm)", "Page size", "Custom scene break", "Part heading style", "Chapter heading style", "Manuscript body headings", "Filename template"]) assert.match(create, new RegExp(label.replace(/[()]/g, "\\$&")));
|
||||
for (const removed of ["Vault folder", "Warn before overwriting", "Open after export", "Reveal after export", "Save to vault"]) assert.doesNotMatch(create, new RegExp(removed));
|
||||
assert.match(settings, /text: "Defaults"/); assert.match(settings, /setName\("Advanced profile"\)/); assert.match(settings, /Used for customised or older workflows/); assert.match(settings, /Advanced profiles, records, and compatibility/); assert.doesNotMatch(settings, /Active legacy profile|Saved defaults/);
|
||||
assert.match(settings, /setName\("Defaults"\)\.setHeading\(\)/); assert.match(settings, /setName\("Advanced profile"\)/); assert.match(settings, /Used for customised or older workflows/); assert.match(settings, /Advanced profiles, records, and compatibility/); assert.doesNotMatch(settings, /Active legacy profile|Saved defaults/);
|
||||
});
|
||||
|
||||
test("workspace controller deduplicates preparation/export and cancels on close", async () => {
|
||||
|
|
@ -453,6 +462,11 @@ test("Contents correction mode lazily renders expanded branches and retains focu
|
|||
assert.match(modal, /renderContentsStep\(body, this\.controller, this\.contentsViewState\)/); assert.doesNotMatch(modal, /renderContentsStep\(body, this\.controller, \(\) => this\.render\(\)\)/);
|
||||
assert.match(contents, /filter\(\(\{ item \}\) => viewState\.isVisible/); assert.match(contents, /viewState\.collapse\(item\.path\)/); assert.match(contents, /focus\(\{ preventScroll: true \}\)/);
|
||||
});
|
||||
test("Correct structure is the primary accessible Contents action with an active finish state", async () => {
|
||||
const contents = await readFile(path.join("src", "workspace", "contents-step.ts"), "utf8"); const css = await readFile("styles.css", "utf8");
|
||||
assert.match(contents, /"Finish correcting structure"\s*:\s*"Correct structure"/); assert.match(contents, /Change folder and note types, inclusion, and order\./); assert.match(contents, /mod-cta manuscript-correct-structure/); assert.match(contents, /setIcon\(icon, "list-tree"\)/); assert.match(contents, /"aria-label": label/); assert.match(contents, /setAttribute\("aria-pressed", String\(viewState\.correctionMode\)\)/); assert.match(contents, /setCorrectionMode\(!viewState\.correctionMode\)/);
|
||||
assert.match(css, /\.manuscript-correct-structure[^}]*min-height:\s*44px/); assert.match(css, /\.manuscript-correct-structure\.is-active/); assert.match(css, /\.manuscript-compile-workspace button:focus-visible/);
|
||||
});
|
||||
test("folder disclosure controls have a 30px target without enlarging note markers or order controls", async () => {
|
||||
const contents = await readFile(path.join("src", "workspace", "contents-step.ts"), "utf8"); const css = await readFile("styles.css", "utf8");
|
||||
assert.equal((contents.match(/function createFolderToggle\(/g) ?? []).length, 1); assert.match(contents, /item\.kind === "folder"[\s\S]*createFolderToggle\(label/);
|
||||
|
|
@ -467,12 +481,14 @@ test("format selector is an accessible responsive radio group with obvious selec
|
|||
const source = await readFile(path.join("src", "workspace", "create-docx-step.ts"), "utf8"); const css = await readFile("styles.css", "utf8");
|
||||
assert.match(source, /text: "Export format"/); assert.match(source, /role: "radiogroup"/); assert.match(source, /role: "radio"/); assert.match(source, /"aria-checked": String\(active\)/);
|
||||
assert.doesNotMatch(source, /setName\(EXPORT_FORMAT_DETAILS\[format\]\.label\)[\s\S]*addDropdown/);
|
||||
for (const label of ["Microsoft Word document", "OpenDocument Text", "Fixed-layout document", "Ebook", "Standalone webpage", "Structured manuscript"]) assert.match(source, new RegExp(label));
|
||||
assert.equal(formatAfterKey("docx", "ArrowRight"), "odt"); assert.equal(formatAfterKey("docx", "ArrowLeft"), "xml"); assert.equal(formatAfterKey("pdf", "ArrowDown"), "epub"); assert.equal(formatAfterKey("html", "ArrowUp"), "epub"); assert.equal(formatAfterKey("pdf", "Home"), "docx"); assert.equal(formatAfterKey("pdf", "End"), "xml"); assert.equal(formatAfterKey("pdf", "Enter"), undefined);
|
||||
for (const label of ["Microsoft Word document", "OpenDocument Text", "Ebook", "Standalone webpage", "Portable plain-text manuscript", "Structured manuscript"]) assert.match(source, new RegExp(label));
|
||||
assert.equal(formatAfterKey("docx", "ArrowRight"), "odt"); assert.equal(formatAfterKey("docx", "ArrowLeft"), "xml"); assert.equal(formatAfterKey("epub", "ArrowDown"), "html"); assert.equal(formatAfterKey("html", "ArrowUp"), "epub"); assert.equal(formatAfterKey("epub", "Home"), "docx"); assert.equal(formatAfterKey("epub", "End"), "xml"); assert.equal(formatAfterKey("epub", "Enter"), undefined);
|
||||
assert.match(css, /\.manuscript-format-option[^}]*min-height:\s*64px/); assert.match(css, /\.manuscript-format-option\.is-selected[^}]*border-color:\s*var\(--interactive-accent\)/); assert.match(css, /@media \(max-width: 700px\)[\s\S]*\.manuscript-format-selector \{ grid-template-columns: 1fr; \}/);
|
||||
});
|
||||
test("combined Create DOCX UI exposes metric indentation and every supported scene break distinctly", async () => {
|
||||
const source = await readFile(path.join("src", "workspace", "create-docx-step.ts"), "utf8"); assert.match(source, /First-line indent \(cm\)/); assert.doesNotMatch(source, /0\.25 in|0\.5 in|inch/i);
|
||||
assert.deepEqual(["docx", "odt", "epub", "html", "markdown", "xml"].map((format) => supportsParagraphIndentation(format as never)), [true, true, true, true, false, false]);
|
||||
assert.match(source, /setName\("Indent first line of paragraphs"\)\.setDesc\("Indent only the first line of later body paragraphs; first paragraphs after headings and scene breaks stay flush left\."\)\.addToggle/); assert.match(source, /indentSize\.settingEl\.hidden = !formatting\.indentParagraphs/); assert.match(source, /Markdown does not support portable first-line indentation\./); assert.match(source, /Paragraph indentation is controlled by the application that consumes the XML\./);
|
||||
for (const option of ["#", "*", "***", "* * *", "", "custom"]) assert.match(source, new RegExp(`addOption\\(\\"${option.replace(/\*/g, "\\*")}\\"`));
|
||||
});
|
||||
|
||||
|
|
@ -483,7 +499,7 @@ test("compile logs persist warning codes, ignore information, and redact absolut
|
|||
|
||||
test("stale prepared source keeps the workspace openable for Refresh Preview", async () => { const loaded = await loadFixtureTree("samples/Book 1 - Warden of Silence"); const plan = await classifyContentPlan(loaded.vault as never, createContentPlan(loaded.root, "novel-parts")); const request = preparedRequest(loaded.root.path, plan); const session = await new CompilePreparationService(loaded.vault as never, createDefaultProfiles()[1], 250).prepare(request, plan); let exports = 0; const controller = new CompileWorkspaceController(request, request.formatting!, { prepare: async () => session, sessionIsCurrent: async () => false, export: async () => { exports += 1; } }); controller.setDetectedPlan(loaded.root.path, plan); controller.state.preparedSession = session; assert.equal(await controller.export(), false); assert.equal(exports, 0); assert.equal(controller.state.preparedSession, undefined); assert.match(controller.state.error?.message ?? "", /Refresh the preview/i); });
|
||||
|
||||
test("main delegates orchestration and ExportCoordinator is the sole exporter owner", async () => { const mainSource = await readFile(path.join("src", "main.ts"), "utf8"); const coordinatorSource = await readFile(path.join("src", "export-coordinator.ts"), "utf8"); const otherProduction = (await Promise.all(["compile-command-service.ts", "compile-modal.ts", "validation.ts"].map((file) => readFile(path.join("src", file), "utf8")))).join("\n"); assert.doesNotMatch(mainSource, /new (DocxMemoryExporter|OdtExporter|PdfExporter|EpubExporter|HtmlExporter|XmlExporter)|recordSuccess\(|recordFailure\(|calculateSourceFingerprint/); assert.match(mainSource, /new ExportCoordinator/); assert.match(coordinatorSource, /exportPreparedSession\(session: PreparedCompileSession/); assert.match(coordinatorSource, /EXPORTERS\[format\]/); assert.doesNotMatch(otherProduction, /EXPORTERS\[/); assert.doesNotMatch(coordinatorSource, /ScannedBook/); });
|
||||
test("main delegates orchestration and ExportCoordinator is the sole exporter owner", async () => { const mainSource = await readFile(path.join("src", "main.ts"), "utf8"); const coordinatorSource = await readFile(path.join("src", "export-coordinator.ts"), "utf8"); const otherProduction = (await Promise.all(["compile-command-service.ts", "compile-modal.ts", "validation.ts"].map((file) => readFile(path.join("src", file), "utf8")))).join("\n"); assert.doesNotMatch(mainSource, /new (DocxMemoryExporter|OdtExporter|EpubExporter|HtmlExporter|MarkdownExporter|XmlExporter)|recordSuccess\(|recordFailure\(|calculateSourceFingerprint/); assert.match(mainSource, /new ExportCoordinator/); assert.match(coordinatorSource, /exportPreparedSession\(session: PreparedCompileSession/); assert.match(coordinatorSource, /EXPORTERS\[format\]/); assert.doesNotMatch(otherProduction, /EXPORTERS\[/); assert.doesNotMatch(coordinatorSource, /ScannedBook/); });
|
||||
|
||||
test("0.9.2 production source has one preparation/export route and no external execution, network, telemetry, or plugin API access", async () => {
|
||||
const files = await sourceFiles("src"); const entries = await Promise.all(files.map(async (file) => [file, await readFile(file, "utf8")] as const)); const source = entries.map(([, content]) => content).join("\n");
|
||||
|
|
@ -494,8 +510,21 @@ test("0.9.2 production source has one preparation/export route and no external e
|
|||
assert.doesNotMatch(source, /createDocx\s*\(/);
|
||||
assert.deepEqual(entries.filter(([, content]) => /pandoc/i.test(content)).map(([file]) => file).sort(), ["src/history-storage.ts", "src/profiles.ts", "src/settings.ts", "src/simple-workflow.ts"]);
|
||||
});
|
||||
test("production source remains mobile-safe, popout-safe, and lifecycle-registered", async () => {
|
||||
const files = await sourceFiles("src"); const source = (await Promise.all(files.map((file) => readFile(file, "utf8")))).join("\n"); const main = await readFile(path.join("src", "main.ts"), "utf8");
|
||||
assert.doesNotMatch(source, /from\s+["'](?:node:|fs["'/]|path["'/]|os["'/]|electron["'/]|child_process["'/])/);
|
||||
assert.doesNotMatch(source, /\bglobalThis\b|\bprocess\.platform\b|document\.createElement|document\.body|\bwindow\.set(?:Timeout|Interval)\b/);
|
||||
assert.doesNotMatch(source, /\.innerHTML\b|\.style\.(?:display|color|background|width|height)|setAttribute\(["']style["']/);
|
||||
assert.doesNotMatch(source, /["']\.obsidian(?:\/|["'])|\bas any\b|\bas TFile\b|\bas TFolder\b/);
|
||||
assert.match(main, /registerEvent\(this\.app\.workspace\.on\("file-menu"/); assert.match(main, /onunload\(\): void \{ this\.operations\.cancel\(\); \}/); assert.doesNotMatch(main, /hotkeys\s*:/);
|
||||
});
|
||||
test("plugin CSS selectors are owned, theme-safe, and retain accessibility modes", async () => {
|
||||
const css = await readFile("styles.css", "utf8"); const selectorBlocks = [...css.matchAll(/(?:^|})(?!\s*@)([^{}]+)\{/g)].map((match) => match[1].trim()).filter((selector) => selector && !selector.startsWith("@"));
|
||||
for (const block of selectorBlocks) for (const selector of block.split(",")) assert.match(selector, /\.manuscript-/, `Unscoped selector: ${selector.trim()}`);
|
||||
assert.doesNotMatch(css, /#[0-9a-f]{3,8}\b|!important/i); assert.match(css, /@media \(forced-colors: active\)/); assert.match(css, /@media \(prefers-reduced-motion: reduce\)/); assert.match(css, /:focus-visible/);
|
||||
});
|
||||
|
||||
test("0.9.2 version metadata and release package allowlist are consistent", async () => { const manifest = JSON.parse(await readFile("manifest.json", "utf8")); const packageJson = JSON.parse(await readFile("package.json", "utf8")); const lock = JSON.parse(await readFile("package-lock.json", "utf8")); const versions = JSON.parse(await readFile("versions.json", "utf8")); const packaging = await readFile(path.join("scripts", "package.mjs"), "utf8"); assert.equal(manifest.version, "0.9.2"); assert.equal(packageJson.version, manifest.version); assert.equal(lock.version, manifest.version); assert.equal(lock.packages[""].version, manifest.version); assert.equal(versions[manifest.version], manifest.minAppVersion); assert.match(packaging, /const required = \["main\.js", "manifest\.json", "styles\.css"\]/); assert.doesNotMatch(packaging, /data\.json|node_modules|\.test-build/); });
|
||||
test("0.9.2 version metadata and release package allowlist are consistent", async () => { const manifest = JSON.parse(await readFile("manifest.json", "utf8")); const packageJson = JSON.parse(await readFile("package.json", "utf8")); const lock = JSON.parse(await readFile("package-lock.json", "utf8")); const versions = JSON.parse(await readFile("versions.json", "utf8")); const packaging = await readFile(path.join("scripts", "package.mjs"), "utf8"); assert.equal(manifest.version, "0.9.2"); assert.equal(packageJson.version, manifest.version); assert.equal(lock.version, manifest.version); assert.equal(lock.packages[""].version, manifest.version); assert.equal(Object.values(versions).at(-1), manifest.minAppVersion); assert.match(packaging, /const required = \["main\.js", "manifest\.json", "styles\.css"\]/); });
|
||||
test("repository hygiene ignores runtime state and rejects tracked plugin data", async () => {
|
||||
for (const candidate of ["data.json", ".obsidian/plugins/manuscript-compiler/data.json", "nested/manuscript-compiler/data.json"]) {
|
||||
await execFileAsync("git", ["check-ignore", "--no-index", "--quiet", candidate]);
|
||||
|
|
|
|||
|
|
@ -6,17 +6,21 @@
|
|||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES2018",
|
||||
"strict": true,
|
||||
"allowJs": false,
|
||||
"noImplicitAny": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"downlevelIteration": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": ["DOM", "ES2018"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
|
|
|
|||
|
|
@ -1,12 +1,3 @@
|
|||
{
|
||||
"0.1.0": "1.5.0",
|
||||
"0.2.0": "1.5.0",
|
||||
"0.3.0": "1.5.0",
|
||||
"0.4.0": "1.5.0",
|
||||
"0.5.0": "1.5.0",
|
||||
"0.6.0": "1.5.0",
|
||||
"0.7.0": "1.5.0",
|
||||
"0.8.0": "1.5.0",
|
||||
"0.9.1": "1.5.0",
|
||||
"0.9.2": "1.5.0"
|
||||
"0.1.0": "1.5.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue