PDFW: use shared writer without reader dependencies

This commit is contained in:
skylar taylor-barrick
2026-10-12 23:06:05 -06:00
parent 159b84f54c
commit d1dff91f6f
13 changed files with 211 additions and 62 deletions
Generated
+11 -15
View File
@@ -843,6 +843,15 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=39adbdf756569c8e03adb747d5e063f468ca98c1#39adbdf756569c8e03adb747d5e063f468ca98c1"
dependencies = [
"krilla",
"thiserror 2.0.21",
]
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -1843,17 +1852,6 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "hayro-write"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a"
dependencies = [
"flate2",
"hayro-syntax",
"pdf-writer",
]
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -2185,15 +2183,13 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
source = "git+https://github.com/storytold/craft-libs?rev=39adbdf756569c8e03adb747d5e063f468ca98c1#39adbdf756569c8e03adb747d5e063f468ca98c1"
dependencies = [
"base64",
"bumpalo",
"flate2",
"float-cmp",
"gif",
"hayro-write",
"image-webp",
"imagesize",
"indexmap",
@@ -5295,12 +5291,12 @@ dependencies = [
name = "wordcraft-pdf"
version = "0.6.1"
dependencies = [
"craft-pdf",
"hayro-cmap",
"hayro-interpret",
"hayro-syntax",
"image",
"js-sys",
"krilla",
"kurbo",
"log",
"thiserror 2.0.21",
+6 -2
View File
@@ -7,7 +7,7 @@ default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
version = "0.6.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/wordcraft"
[workspace.dependencies]
@@ -52,7 +52,7 @@ harfrust = "0.13"
wuff = "0.2.9"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
krilla = { version = "0.8", features = ["pdf"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "39adbdf756569c8e03adb747d5e063f468ca98c1", version = "=0.1.0", default-features = false, features = ["write"] }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
@@ -95,3 +95,7 @@ opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "39adbdf756569c8e03adb747d5e063f468ca98c1" }
+6 -1
View File
@@ -55,6 +55,11 @@ list (author, source, license) is in ATTRIBUTION.md.
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources.
The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision
39adbdf756569c8e03adb747d5e063f468ca98c1. The backend retains its MIT OR Apache-2.0 licensing
and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md.
The backend patch prevents finalization during an unwind and rejects a writer after failed
cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled.
No Microsoft, Adobe, Avid or Autodesk icons, images, templates or artwork are included.
+1 -1
View File
@@ -188,7 +188,7 @@ covers every target starting with it (`wordcraft*=debug`). The logger is
| L0 | `wordcraft-geom` | units and measurements |
| L1 | `wordcraft-doc`, `wordcraft-fonts`, `wordcraft-proof` | document model and editing; fonts and shaping; spelling, grammar, hyphenation |
| L2 | `wordcraft-layout`, `wordcraft-docx`, `wordcraft-formats` | line breaking, pagination, tables, notes, hit testing; OOXML; ODT/RTF/HTML/Markdown/TXT |
| L3 | `wordcraft-render`, `wordcraft-pdf` | rasteriser (vello_cpu); PDF (krilla) |
| L3 | `wordcraft-render`, `wordcraft-pdf` | rasteriser (vello_cpu); PDF ([craft-pdf](docs/shared-pdf.md), Krilla) |
| L4 | `wordcraft-engine` | session, undo, 422 commands, Word feature catalog |
| L5 | `wordcraft-mcp` | MCP server |
| L6 | `wordcraft-ui-egui` | the Word-style front end (swappable) |
+1
View File
@@ -149,6 +149,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-12 | Start screen (#766): launched with nothing to open, WordCraft starts on File › Home (Blank document, templates, Recent) like Word, desktop and web; Esc leaves a blank document; File › Options › General › Start-up turns it off |
| 2026-10-12 | Word 365 cover pages (#743): a freeform inside a group keeps its shape (it was drawn as a rectangle); text boxes keep their own internal margins and Resize shape to fit text, so a tight title box no longer loses its second line; both survive saving. The missing fills in the report were theme-coloured shapes, fixed by #611 |
| 2026-10-12 | Alt+Q moves to "Tell me" command search, as in Word for Windows (#819); not on macOS, where Option+Q types a character |
| 2026-10-12 | PDFW: PDF export uses the shared craft-pdf writer for metadata, font preparation and serialization while retaining WordCraft layout, drawing, tagging, links and font-outline retry. Writer-only builds do not enable PDF import codecs. |
| 2026-10-12 | PDF export of long documents with many internal links (a linked table of contents) is linear: NIH's 67-page protocol template 10.6 s → 0.9 s |
| 2026-10-12 | Keytips: Word for Windows' tab letters (File F, Design G, References S, Review R, View W, Help Y) |
| 2026-10-12 | Table-of-contents links look like Word's: plain text, not blue and underlined (Word ignores the Hyperlink style on TOC entry links; measured in 9 cases) |
+1 -1
View File
@@ -154,7 +154,7 @@ pub fn compile(query: &str, o: &Opts) -> Result<Compiled, String> {
}
};
// Word's wildcard search always matches case.
let case_insensitive = !o.match_case && !(o.wildcards && !o.regex);
let case_insensitive = (o.regex || !o.wildcards) && !o.match_case;
Ok(Compiled { pattern: pat, case_insensitive, spans_paras: spans })
}
-1
View File
@@ -462,7 +462,6 @@ mod tests {
assert!(run(json!({"rename": {"tab": "Writing", "to": "insert"}})).is_err());
assert!(run(json!({"add": {"tab": "Writing", "group": "New Group", "command": "no.such"}})).is_err());
assert!(run(json!({"move": {"tab": "Nope", "by": 1}})).is_err());
drop(run);
assert!(s.ui_requests.is_empty(), "programmatic calls never open dialogs");
s.run("tools.customizeRibbon", &json!({"reset": true})).unwrap();
assert_eq!(s.ribbon, RibbonLayout::default());
+2 -2
View File
@@ -3,7 +3,7 @@ name = "wordcraft-pdf"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "WordCraft PDF export (krilla): embedded font subsets with selectable text, links, outline, tagging"
description = "WordCraft PDF export (craft-pdf / Krilla): embedded font subsets with selectable text, links, outline, tagging"
[lints]
workspace = true
@@ -14,7 +14,7 @@ wordcraft-geom = { workspace = true }
wordcraft-fonts = { workspace = true }
wordcraft-layout = { workspace = true }
wordcraft-metafile = { workspace = true }
krilla = { workspace = true }
craft-pdf = { workspace = true }
kurbo = { workspace = true }
image = { workspace = true }
thiserror = { workspace = true }
+61 -37
View File
@@ -1,4 +1,4 @@
//! WordCraft PDF export, written with `krilla`.
//! WordCraft PDF export through the shared `craft-pdf` writer and Krilla drawing primitives.
//!
//! [`export`] lays the document out and draws each page's display list
//! ([`wordcraft_layout::display::page_display`]) into a PDF page:
@@ -26,6 +26,7 @@ use std::collections::{HashMap, HashSet};
use std::num::NonZeroU16;
use std::sync::Arc;
use craft_pdf::write::{self, Metadata, WriteError, Writer, backend as krilla};
use krilla::action::{Action, LinkAction};
use krilla::annotation::{Annotation, LinkAnnotation, Target};
use krilla::color::rgb;
@@ -33,7 +34,7 @@ use krilla::destination::XyzDestination;
use krilla::error::KrillaError;
use krilla::geom::{Path, PathBuilder, Point, Size, Transform};
use krilla::image::Image;
use krilla::metadata::{DateTime, Metadata};
use krilla::metadata::DateTime;
use krilla::num::NormalizedF32;
use krilla::outline::{Outline, OutlineNode};
use krilla::page::PageSettings;
@@ -66,6 +67,12 @@ pub enum PdfError {
Write(String),
}
impl From<WriteError> for PdfError {
fn from(error: WriteError) -> Self {
Self::Write(error.to_string())
}
}
/// Export options.
#[derive(Clone, Debug)]
pub struct PdfOptions {
@@ -95,17 +102,23 @@ impl Default for PdfOptions {
/// deletions are left out, as in Word's "No Markup" view. With `comments` each page carries the
/// markup area with the comment balloons.
pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
let lo = LayoutOptions { hide_deleted: !opts.include_markup, comment_balloons: opts.comments, ..Default::default() };
let lay = layout(doc, &mut LayoutCache::new(), &lo);
export_layout(doc, &lay, opts)
write::guard(|| {
let lo = LayoutOptions { hide_deleted: !opts.include_markup, comment_balloons: opts.comments, ..Default::default() };
let lay = layout(doc, &mut LayoutCache::new(), &lo);
export_layout_inner(doc, &lay, opts)
})
}
/// Write an existing layout of `doc` as PDF.
pub fn export_layout(doc: &Document, lay: &DocLayout, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
write::guard(|| export_layout_inner(doc, lay, opts))
}
fn export_layout_inner(doc: &Document, lay: &DocLayout, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
let pages: Vec<usize> = match &opts.pages {
Some(v) => {
if let Some(bad) = v.iter().find(|p| **p >= lay.pages.len()) {
return Err(PdfError::BadPage(*bad + 1));
return Err(PdfError::BadPage(bad.saturating_add(1)));
}
v.clone()
}
@@ -118,7 +131,7 @@ pub fn export_layout(doc: &Document, lay: &DocLayout, opts: &PdfOptions) -> Resu
// the whole export. Draw that font's glyphs as outlines instead and write the document again.
let mut outlined = HashSet::new();
loop {
match write(doc, lay, opts, &pages, &outlined)? {
match write_attempt(doc, lay, opts, &pages, &outlined)? {
Attempt::Done(bytes) => return Ok(bytes),
Attempt::BadFont(id, msg) if outlined.len() < MAX_OUTLINED_FONTS && outlined.insert(id) => {
log::warn!("PDF: {msg}; its text is drawn as outlines");
@@ -138,10 +151,11 @@ enum Attempt {
BadFont(u32, String),
}
fn write(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[usize], outlined: &HashSet<u32>) -> Result<Attempt, PdfError> {
fn write_attempt(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[usize], outlined: &HashSet<u32>) -> Result<Attempt, PdfError> {
let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, enable_tagging: opts.tagged, ..Default::default() };
let mut pdf = krilla::Document::new_with(settings);
pdf.set_metadata(metadata(doc, opts));
let language = doc.styles.default_chr.lang.clone().filter(|l| !l.is_empty() && l.is_ascii());
let mut writer = Writer::new_with_language(settings, metadata(doc, opts), language);
let pdf = writer.document_mut();
let mut ex = Exporter {
doc,
@@ -149,6 +163,7 @@ fn write(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[usize], ou
opts,
out_index: pages.iter().enumerate().map(|(o, p)| (*p, o)).collect(),
fonts: HashMap::new(),
font_error: None,
outlined,
cmaps: HashMap::new(),
images: HashMap::new(),
@@ -162,7 +177,7 @@ fn write(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[usize], ou
pdf.set_outline(o);
}
for &pi in pages {
let Some(page) = lay.pages.get(pi) else { return Err(PdfError::BadPage(pi + 1)) };
let Some(page) = lay.pages.get(pi) else { return Err(PdfError::BadPage(pi.saturating_add(1))) };
let fit = Fit::of(page);
let (w, h) = (clamp_side(fit.paper_w), clamp_side(page.h));
let size = Size::from_wh(w, h).ok_or(PdfError::NoPages)?;
@@ -188,16 +203,22 @@ fn write(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[usize], ou
if opts.tagged {
pdf.set_tag_tree(ex.tag_tree());
}
match pdf.finish() {
if let Some(error) = ex.font_error {
return Err(error);
}
match writer.finish() {
Ok(bytes) => Ok(Attempt::Done(bytes)),
Err(KrillaError::Font(font, msg)) => {
let bad = ex.fonts.iter().find(|(_, (_, f))| f.as_ref() == Some(&font));
match bad {
Some((id, (face, _))) => Ok(Attempt::BadFont(*id, format!("font {} {} could not be embedded ({msg})", face.family, face.style))),
None => Err(PdfError::Write(format!("a font could not be embedded ({msg})"))),
Err(WriteError::Serialization(error)) => match *error {
KrillaError::Font(font, msg) => {
let bad = ex.fonts.iter().find(|(_, (_, f))| f.as_ref() == Some(&font));
match bad {
Some((id, (face, _))) => Ok(Attempt::BadFont(*id, format!("font {} {} could not be embedded ({msg})", face.family, face.style))),
None => Err(PdfError::Write(format!("a font could not be embedded ({msg})"))),
}
}
}
Err(e) => Err(PdfError::Write(format!("{e:?}"))),
error => Err(PdfError::Write(format!("{error:?}"))),
},
Err(error) => Err(error.into()),
}
}
@@ -266,32 +287,28 @@ fn clamp_side(v: f32) -> f32 {
fn metadata(doc: &Document, opts: &PdfOptions) -> Metadata {
let c = &doc.core;
let mut m = Metadata::new().creator("WordCraft".into()).producer("WordCraft".into());
let mut m = Metadata { creator: "WordCraft".into(), producer: "WordCraft".into(), ..Default::default() };
let title = opts.title.clone().unwrap_or_else(|| c.title.clone());
if !title.trim().is_empty() {
m = m.title(title);
m.title = Some(title);
}
let author = opts.author.clone().unwrap_or_else(|| c.creator.clone());
if !author.trim().is_empty() {
m = m.authors(vec![author]);
m.authors = vec![author];
}
if !c.subject.trim().is_empty() {
m = m.description(c.subject.clone());
m.description = Some(c.subject.clone());
} else if !c.description.trim().is_empty() {
m = m.description(c.description.clone());
m.description = Some(c.description.clone());
}
let kw: Vec<String> = c.keywords.split([',', ';']).map(|k| k.trim().to_string()).filter(|k| !k.is_empty()).collect();
if !kw.is_empty() {
m = m.keywords(kw);
}
if let Some(lang) = doc.styles.default_chr.lang.clone().filter(|l| !l.is_empty() && l.is_ascii()) {
m = m.language(lang);
m.keywords = kw;
}
let date = parse_iso(&c.created).or_else(|| now_unix().map(civil));
if let Some((y, mo, d, h, mi, s)) = date {
m = m.creation_date(
DateTime::new(y.clamp(0, 9999) as u16).month(mo).day(d).hour(h).minute(mi).second(s).utc_offset_hour(0).utc_offset_minute(0),
);
m.created =
Some(DateTime::new(y.clamp(0, 9999) as u16).month(mo).day(d).hour(h).minute(mi).second(s).utc_offset_hour(0).utc_offset_minute(0));
}
m
}
@@ -513,6 +530,7 @@ struct Exporter<'a> {
out_index: HashMap<usize, usize>,
/// Embedded fonts by face id (`None`: krilla can't read it).
fonts: HashMap<u32, (FaceRef, Option<Font>)>,
font_error: Option<PdfError>,
/// Faces whose glyphs are drawn as outlines because krilla can't subset them.
outlined: &'a HashSet<u32>,
cmaps: HashMap<u32, Arc<HashMap<u32, char>>>,
@@ -545,15 +563,21 @@ impl Exporter<'_> {
if self.outlined.contains(&face.id()) {
return None;
}
let font_error = &mut self.font_error;
self.fonts
.entry(face.id())
.or_insert_with(|| {
let data: krilla::Data = face.data().to_vec().into();
let font = if face.is_variable() {
let coords: Vec<(krilla::text::Tag, f32)> = face.coords.iter().map(|(t, v)| (krilla::text::Tag::new(t), *v)).collect();
Font::new_variable(data, face.index(), &coords)
} else {
Font::new(data, face.index())
let variations = face.is_variable().then_some(face.coords.as_slice());
let font = match write::font(face.data(), face.index(), variations) {
Ok(font) => Some(font),
// Keep WordCraft's existing policy: unreadable faces are skipped.
Err(WriteError::InvalidFont) => None,
Err(error) => {
// Allocation, validation or backend failures must fail the export,
// rather than return a PDF with missing text.
font_error.get_or_insert_with(|| error.into());
None
}
};
(*face, font)
})
+75
View File
@@ -814,6 +814,7 @@ fn bookmark_destination_is_the_page_of_its_start() {
opts: &PdfOptions::default(),
out_index: pages.iter().enumerate().map(|(o, p)| (*p, o)).collect(),
fonts: HashMap::new(),
font_error: None,
outlined: &outlined,
cmaps: HashMap::new(),
images: HashMap::new(),
@@ -902,3 +903,77 @@ fn internal_links_export_in_linear_time() {
let ratio = fastest(&big) / fastest(&small).max(1e-9);
assert!(ratio < 9.0, "4x the paragraphs and links took {ratio:.1}x as long (quadratic would be ~16x)");
}
#[test]
fn extreme_page_index_returns_an_error() {
let d = Document::from_text("Hostile page selection");
let lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let opts = PdfOptions { pages: Some(vec![usize::MAX]), ..Default::default() };
assert_eq!(export(&d, &opts), Err(PdfError::BadPage(usize::MAX)));
assert_eq!(export_layout(&d, &lay, &opts), Err(PdfError::BadPage(usize::MAX)));
}
#[test]
fn metadata_language_overrides_and_fallbacks_are_preserved() {
let mut d = Document::from_text("Metadata export");
d.core.created = "2024-05-06T07:08:09Z".into();
d.core.title = "Original title".into();
d.core.creator = "Original author".into();
d.core.description = "Description fallback".into();
d.core.keywords = "first; second, third;;".into();
let opts = PdfOptions {
title: Some("Override title".into()),
author: Some("Override author".into()),
tagged: false,
compress: false,
..Default::default()
};
for language in [Some("es-CR"), Some("ja"), Some(""), Some("日本語"), None] {
d.styles.default_chr.lang = language.map(str::to_owned);
let bytes = export(&d, &opts).unwrap();
let raw = String::from_utf8_lossy(&bytes);
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let catalog = pdf.xref().get::<hayro_syntax::object::Dict<'_>>(pdf.xref().root_id()).unwrap();
let actual_language = catalog.get::<hayro_syntax::object::String<'_>>(b"Lang");
for value in ["Override title", "Override author", "Description fallback", "first, second, third", "D:20240506070809"] {
assert!(raw.contains(value), "missing {value}");
}
if let Some(language) = language.filter(|s| !s.is_empty() && s.is_ascii()) {
assert_eq!(actual_language.unwrap().as_bytes(), language.as_bytes());
assert!(raw.contains(&format!("<dc:language><rdf:Bag><rdf:li>{language}</rdf:li>")));
} else {
assert!(actual_language.is_none());
assert!(!raw.contains("<dc:language>"));
}
}
}
#[test]
fn bundled_unicode_glyphs_are_visible_and_extractable() {
const TEXT: &str = "café naïve — coöperate Ελληνικά Привет Tiếng Việt";
let fonts = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let bundled = fonts.face("Source Sans 3", "Regular");
assert_eq!(bundled.family, "Source Sans 3");
assert_eq!(bundled.style, "Regular");
for ch in TEXT.chars() {
assert!(bundled.covers(ch), "the explicitly bundled face must cover {ch:?}");
}
let mut d = Document::new();
d.core.created = "2024-05-06T07:08:09Z".into();
d.body = vec![para_block(Paragraph::with_text(TEXT, CharProps { font: Some("Source Sans 3".into()), size: Some(14.0), ..Default::default() }))];
let lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let mut drawn = String::new();
for page in &lay.pages {
for draw in page_display(&d, page, &DisplayOptions::default()) {
if let Draw::Glyphs { face, glyphs, text, .. } = draw {
assert_eq!(face.data(), bundled.data(), "page glyphs must use the bundled face without system fallback");
assert!(glyphs.iter().all(|(id, _, _)| *id != 0), "page glyphs must not use .notdef");
drawn.push_str(&text);
}
}
}
assert_eq!(squash(&drawn), TEXT);
let bytes = export_layout(&d, &lay, &PdfOptions::default()).unwrap();
assert!(String::from_utf8_lossy(&bytes).contains("/ToUnicode"));
assert_eq!(squash(&extract_text(&bytes).concat()), TEXT);
}
+1 -1
View File
@@ -87,7 +87,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect]
}
return true;
}
if !available(app) || !(ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer()) {
if !(available(app) && ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer()) {
return false;
}
let Some(p) = latest else { return false };
+1 -1
View File
@@ -150,7 +150,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect]
}
return true;
}
if !available(app) || !(ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer()) {
if !(available(app) && ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer()) {
return false;
}
let Some(p) = latest else { return false };
+45
View File
@@ -0,0 +1,45 @@
# Shared PDF writer
WordCraft uses `craft-pdf` with default features disabled and `write` enabled. Both that dependency
and the required root Krilla patch use the immutable `storytold/craft-libs` revision
`39adbdf756569c8e03adb747d5e063f468ca98c1`. Rust 1.95 or newer is required. The revision must be
available from the remote before a fresh checkout can resolve it. The shared writer checks a
backend marker at compile time so an unpatched Krilla cannot replace the required backend.
The shared writer owns document construction, metadata serialization, fallible font preparation
and final serialization. WordCraft owns layout, pagination, selected page order, markup and
hidden-text policy, comments, drawing, field text, link annotations, heading outlines and tagged
structure. It retains its bounded retry with glyph outlines when a selected font cannot be
subsetted. Each retry constructs a fresh writer. Readable fonts otherwise remain embedded,
selectable text.
WordCraft supplies its existing metadata policy: option overrides, subject/description fallback,
split keywords, and nonempty ASCII language tags. Creation dates come from the document when valid,
otherwise from the native system clock or the browser clock on WebAssembly. The shared writer
never chooses a clock or changes this policy.
## Backend sources and features
Krilla remains 0.8.2 with its default raster-image and simple-text support. WordCraft does not
embed existing PDF pages, so it does not enable Krilla's optional `pdf` feature or the shared
`write-pdf` feature. The shared writer has no parser dependency. Hayro syntax and interpretation
remain explicit test dependencies at their existing registry versions; no parser or codec fork
is introduced. The existing `craft-print` and `craft-ui` dependency pins are retained separately.
Krilla's existing bundled profiles retain their upstream licences, recorded in the pinned shared
repository's `licenses/pdf-writer/` and `ATTRIBUTION.md`. This migration
adds no WordCraft font or profile binaries.
## Failure handling
Both public PDF export functions guard the full native operation, including layout where that
function performs it. The vendored Krilla skips surface/page finalization while already unwinding,
preventing a second destructor panic from aborting before the guard can return an error. Failed
page or surface cleanup permanently marks that document as failed, so final serialization rejects
a retained writer even if later pages finish successfully. Successful serialization is unchanged.
Unreadable fonts retain WordCraft's existing skip policy. Font allocation, variation-validation
and backend failures fail the export instead of returning incomplete text. A serialization error
for an unsubsettable font still enters WordCraft's existing bounded outline retry. The guard does
not cover preparation done by a caller before invoking a PDF export function, WebAssembly aborts,
or process-wide allocation failure.