diff --git a/Cargo.lock b/Cargo.lock index ae82a38c9..b4bf66566 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 4e4f69915..630347498 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/NOTICE b/NOTICE index 8d09e43bd..0c55f9c89 100644 --- a/NOTICE +++ b/NOTICE @@ -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. diff --git a/README.md b/README.md index 2929018bb..989f42959 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/ROADMAP.md b/ROADMAP.md index c331514cf..c215734b1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | diff --git a/crates/engine/src/cmd/find_pattern.rs b/crates/engine/src/cmd/find_pattern.rs index 2c39bca3a..c36c4acb2 100644 --- a/crates/engine/src/cmd/find_pattern.rs +++ b/crates/engine/src/cmd/find_pattern.rs @@ -154,7 +154,7 @@ pub fn compile(query: &str, o: &Opts) -> Result { } }; // 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 }) } diff --git a/crates/engine/src/ribbon.rs b/crates/engine/src/ribbon.rs index 341e7e76a..49cb8793d 100644 --- a/crates/engine/src/ribbon.rs +++ b/crates/engine/src/ribbon.rs @@ -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()); diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 46aa52a78..d85446ed9 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -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 } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 6b370fbf9..d296db225 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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 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, 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, PdfError> { + write::guard(|| export_layout_inner(doc, lay, opts)) +} + +fn export_layout_inner(doc: &Document, lay: &DocLayout, opts: &PdfOptions) -> Result, PdfError> { let pages: Vec = 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) -> Result { +fn write_attempt(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[usize], outlined: &HashSet) -> Result { 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 = 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, /// Embedded fonts by face id (`None`: krilla can't read it). fonts: HashMap)>, + font_error: Option, /// Faces whose glyphs are drawn as outlines because krilla can't subset them. outlined: &'a HashSet, cmaps: HashMap>>, @@ -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) }) diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 5ce20dd58..794024ced 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -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::>(pdf.xref().root_id()).unwrap(); + let actual_language = catalog.get::>(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!("{language}"))); + } else { + assert!(actual_language.is_none()); + assert!(!raw.contains("")); + } + } +} + +#[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); +} diff --git a/crates/ui-egui/src/table_mouse.rs b/crates/ui-egui/src/table_mouse.rs index b28940475..fcb307450 100644 --- a/crates/ui-egui/src/table_mouse.rs +++ b/crates/ui-egui/src/table_mouse.rs @@ -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 }; diff --git a/crates/ui-egui/src/table_resize.rs b/crates/ui-egui/src/table_resize.rs index 33cfd267d..add918d2a 100644 --- a/crates/ui-egui/src/table_resize.rs +++ b/crates/ui-egui/src/table_resize.rs @@ -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 }; diff --git a/docs/shared-pdf.md b/docs/shared-pdf.md new file mode 100644 index 000000000..e85f9c6f2 --- /dev/null +++ b/docs/shared-pdf.md @@ -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.