mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 02:17:24 -06:00
PDFW: use shared writer without reader dependencies
This commit is contained in:
Generated
+11
-15
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user