diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index e0759667..5a8d91ce 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -52,7 +52,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[ default: "null", description: "colour conversion to a destination profile, ICC profiles, output intent and Trapped (see document.exportPdf)", }, - FormatOption { name: "advanced", ty: "object", default: "null", description: "{fontSubsetPercent, outlineText, overprint}" }, + FormatOption { + name: "advanced", + ty: "object", + default: "null", + description: "{fontSubsetPercent, outlineText (false: real text in embedded subset fonts), overprint}", + }, FormatOption { name: "security", ty: "object", @@ -74,7 +79,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100, outlineText: true, overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, PDF/A-2b at 2.0 and a password with a PDF/A or PDF/X standard are refused. document.export {format: pdf} takes the same options", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, PDF/A-2b at 2.0 and a password with a PDF/A or PDF/X standard are refused. document.export {format: pdf} takes the same options", has_doc, export_pdf ), diff --git a/crates/engine/src/cmd/fileio/tests_pdfoutput.rs b/crates/engine/src/cmd/fileio/tests_pdfoutput.rs index ac0937b7..04726fee 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfoutput.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfoutput.rs @@ -1,5 +1,5 @@ -//! `document.exportPdf` output options reach the writer: colours converted and tagged with -//! profiles, and the output intent written. +//! `document.exportPdf` output and advanced options reach the writer: colours converted and tagged +//! with profiles, the output intent written, and type as real text. use serde_json::{Value, json}; @@ -12,7 +12,7 @@ fn pdf(s: &mut Session, p: Value) -> (String, Value) { } #[test] -fn export_pdf_converts_colours_and_writes_the_output_intent() { +fn export_pdf_converts_colours_writes_the_output_intent_and_real_text() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); @@ -23,11 +23,13 @@ fn export_pdf_converts_colours_and_writes_the_output_intent() { "preset": "VectorCraft Default", "compression": {"compressText": false}, "output": {"conversion": "destination", "destination": generic, "profiles": "destination", "outputIntent": generic, "outputConditionId": "Generic press", "trapped": true}, + "advanced": {"outlineText": false}, }); let (text, warnings) = pdf(&mut s, p.clone()); assert_eq!(warnings, json!([]), "everything asked for is applied"); assert!(text.contains("/ICCBased") && text.contains("/N 4"), "CMYK colours tagged with the destination"); assert!(text.contains("/S/GTS_PDFX") && text.contains("/Trapped/True")); + assert!(text.contains("/ToUnicode") && (text.contains("/FontFile2") || text.contains("/FontFile3")), "real text"); // The Summary has nothing to report either. let mut q = p; q["includeDocument"] = json!(true); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 4b97812e..61571280 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1,5 +1,6 @@ //! Document → PDF (krilla). Mirrors the tree walk of `vectorcraft-render`. +use std::borrow::Cow; use std::collections::HashMap; use std::sync::Arc; @@ -13,11 +14,14 @@ use krilla::num::NormalizedF32; use krilla::page::PageSettings; use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash}; use krilla::surface::Surface; +use krilla::text::{GlyphId, KrillaGlyph}; use kurbo::{PathEl, Shape, Vec2}; use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject}; use vectorcraft_effects::stroke::{self, WrittenShape}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect}; +use vectorcraft_text::TextLayout; +use vectorcraft_text::embed::Embedding; use crate::lab_spot::{find, rfind}; use crate::marks::PageBoxes; @@ -423,6 +427,11 @@ pub(crate) struct Exporter<'a> { pub out: Arc, /// Colours are converted to the destination (printer's marks aren't). convert: bool, + /// Text as glyph outlines; else the characters' fills are real text in embedded fonts. + outline_text: bool, + /// The fonts real text is written in, by face id, with their units per em; `None` for faces + /// that can't be embedded. + fonts: HashMap>, } impl<'a> Exporter<'a> { @@ -443,6 +452,8 @@ impl<'a> Exporter<'a> { transparent: None, out: Arc::default(), convert: true, + outline_text: set.advanced.outline_text, + fonts: HashMap::new(), } } @@ -453,6 +464,18 @@ impl<'a> Exporter<'a> { } } +/// Glyphs written together as real text: the same font at the same size along one baseline. +struct GlyphLine { + face: u32, + font: krilla::text::Font, + size: f64, + /// The glyph space (krilla's, y down, at `size`) → text space, at the line's start. + frame: Affine, + /// Glyph id, pen position and advance along the line (in glyph space), and the characters it + /// stands for (bytes of the plain text). + glyphs: Vec<(u32, f64, f64, std::ops::Range)>, +} + /// Most pixels along a side of a freeform gradient's image (its colour field is smooth). const MAX_FIELD_PX: f64 = 512.0; @@ -645,6 +668,12 @@ impl Exporter<'_> { /// Convert a paint. `bounds` resolves unset gradient geometry (like the renderer). fn paint(&mut self, p: &Paint, bounds: Rect) -> Option { + self.paint_in(p, bounds, Affine::IDENTITY) + } + + /// [`Self::paint`] drawn in another space: `space` maps the paint's space (where `bounds` + /// is) to the one drawn in. + fn paint_in(&mut self, p: &Paint, bounds: Rect, space: Affine) -> Option { match p { Paint::None => None, Paint::Solid { color: c, swatch, tint } => Some(self.solid(c, swatch.as_deref(), *tint).into()), @@ -690,7 +719,7 @@ impl Exporter<'_> { y1: s.y as f32, x2: e.x as f32, y2: e.y as f32, - transform: Transform::identity(), + transform: xf(space), spread_method: SpreadMethod::Pad, stops, anti_alias: false, @@ -712,7 +741,7 @@ impl Exporter<'_> { cx, cy, cr: r, - transform: xf(t), + transform: xf(space * t), spread_method: SpreadMethod::Pad, stops, anti_alias: false, @@ -1194,21 +1223,31 @@ impl Exporter<'_> { self.text_items(s, below, bp, path, page, tb); } s.push_transform(&xf(t.xf)); + // Real text: the characters' fills as text in embedded fonts. + let plain = (!self.outline_text).then(|| t.plain_text()); for (i, run) in t.runs.iter().enumerate() { let mut bp = BezPath::new(); for g in layout.glyphs.iter().filter(|g| g.run == i) { bp.extend(g.outline.iter()); } - let Some(path) = to_path(&bp) else { continue }; + let fill = &run.style.fill; + // The outlines left to fill as paths: all of them, or those real text leaves. + let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !self.area_paint(fill)) { + Some(text) => Cow::Owned(self.glyph_text(s, &layout, i, text, fill)), + None => Cow::Borrowed(&bp), + }; let opaque = (1.0, BlendMode::Normal); - if !self.area(s, &path, krilla::paint::FillRule::NonZero, &run.style.fill, opaque, layout.bounds, bp.bounding_box()) - && let Some(paint) = self.paint(&run.style.fill, layout.bounds) + if let Some(path) = to_path(&outlines) + && !self.area(s, &path, krilla::paint::FillRule::NonZero, fill, opaque, layout.bounds, outlines.bounding_box()) + && let Some(paint) = self.paint(fill, layout.bounds) { s.set_stroke(None); s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); s.draw_path(&path); } - if run.style.has_stroke() { + if run.style.has_stroke() + && let Some(path) = to_path(&bp) + { // Character strokes are drawn in text space, with their cap, join and dashes. self.stroke(s, &bp, &path, FillRule::NonZero, &run.style.stroke_layer(), page, layout.bounds); } @@ -1244,6 +1283,118 @@ impl Exporter<'_> { s.set_stroke(None); } + /// Fill the glyphs of run `run` of `layout` with `fill` as text in their fonts, embedded (in + /// text space; `text` is the plain text, whose characters the glyphs stand for, which makes + /// the text selectable and searchable) → the outlines of the glyphs left to draw as paths: + /// those of fonts that can't be embedded, and missing glyphs. Glyphs sharing a font, a frame + /// (size, scale, rotation) and a baseline are written together. + fn glyph_text(&mut self, s: &mut Surface, layout: &TextLayout, run: usize, text: &str, fill: &Paint) -> BezPath { + let mut rest = BezPath::new(); + let mut line: Option = None; + for g in layout.glyphs.iter().filter(|g| g.run == run) { + let range = g.byte..g.byte + g.len; + let ch = text.get(range.clone()).and_then(|t| t.chars().next()); + // Spaces are written too (text extraction needs them); control characters and soft + // hyphens draw nothing. + let blank = g.outline.elements().is_empty(); + if blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control()) { + continue; + } + // The glyph at the font size of its vertical scale: `frame` maps that size's glyph + // space (y down, krilla's) to text space. + let [a, b, c, d, ..] = g.xf.as_coeffs(); + let (sx, sy) = (a.hypot(b), c.hypot(d)); + let font = if g.gid != 0 && ch.is_some() { self.font(g.font_id) } else { None }; + let det = g.xf.determinant(); + let Some((font, upem)) = font.filter(|(_, upem)| det.is_finite() && det.abs() > 1e-9 && (1e-3..=1e5).contains(&(upem * sy))) else { + rest.extend(g.outline.iter()); + continue; + }; + let size = upem * sy; + let frame = g.xf * Affine::scale(1.0 / sy); + // Its pen position along the frame's baseline, when it continues the line. + let x = line.as_ref().filter(|l| l.face == g.font_id && (l.size - size).abs() < 1e-6).and_then(|l| { + let [a, b, c, d, e, f] = (l.frame.inverse() * frame).as_coeffs(); + let same = (a - 1.0).abs() < 1e-6 && b.abs() < 1e-6 && c.abs() < 1e-6 && (d - 1.0).abs() < 1e-6 && f.abs() < 1e-4; + same.then_some(e) + }); + let x = match x { + Some(x) => x, + None => { + if let Some(l) = line.take() { + self.draw_glyph_line(s, l, text, fill, layout.bounds); + } + line = Some(GlyphLine { face: g.font_id, font, size, frame, glyphs: vec![] }); + 0.0 + } + }; + // The glyph's advance in frame units (the last one's ends the line). + let advance = g.advance * sy / sx; + if let Some(l) = line.as_mut() { + l.glyphs.push((g.gid, x, advance, range)); + } + } + if let Some(l) = line { + self.draw_glyph_line(s, l, text, fill, layout.bounds); + } + rest + } + + /// Draw glyphs `line` (of `text`), filled with `fill` (whose space is text space, where + /// `bounds` is). + fn draw_glyph_line(&mut self, s: &mut Surface, line: GlyphLine, text: &str, fill: &Paint, bounds: Rect) { + let Some(paint) = self.paint_in(fill, bounds, line.frame.inverse()) else { return }; + let size = line.size; + let glyphs: Vec = line + .glyphs + .iter() + .enumerate() + .map(|(i, (gid, x, advance, range))| { + let next = line.glyphs.get(i + 1).map_or(x + advance, |n| n.1); + KrillaGlyph::new(GlyphId::new(*gid), ((next - x) / size) as f32, 0.0, 0.0, 0.0, range.clone(), None) + }) + .collect(); + s.push_transform(&xf(line.frame)); + s.set_stroke(None); + s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); + s.draw_glyphs(krilla::geom::Point::from_xy(0.0, 0.0), &glyphs, line.font, text, size as f32, false); + s.pop(); + } + + /// The font of face `id` for real text and its units per em; `None` (with a warning) when its + /// licence doesn't allow embedding it as a subset, or it can't be read. + fn font(&mut self, id: u32) -> Option<(krilla::text::Font, f64)> { + if let Some(f) = self.fonts.get(&id) { + return f.clone(); + } + let Some(face) = vectorcraft_text::FontDb::global().face_by_id(id) else { + self.fonts.insert(id, None); + return None; + }; + let name = format!("{} {}", face.family, face.style); + let font = match face.embedding() { + Embedding::Subset => { + let font = krilla::text::Font::new(face.file_data().to_vec().into(), face.face_index()).map(|f| (f, face.units_per_em())); + if font.is_none() { + self.warn(format!("the font “{name}” can't be embedded: its text is exported as outlines")); + } + font + } + Embedding::Whole => { + self.warn(format!( + "the font “{name}” may only be embedded whole, and fonts are embedded as subsets: its text is exported as outlines" + )); + None + } + Embedding::Forbidden => { + self.warn(format!("the licence of the font “{name}” doesn't allow embedding: its text is exported as outlines")); + None + } + }; + self.fonts.insert(id, font.clone()); + font + } + /// Image `key` placed `size` points wide and high, resampled and compressed as the Compression /// settings say ([`crate::images::recode`]); cached by key (and size when images are /// resampled). diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index a3b23c13..eddaa569 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -6,9 +6,9 @@ //! as their brush art), opacity and blend modes (transparency groups), //! clip groups, linear/radial gradients (shadings), pattern fills and strokes as their tiles and //! freeform gradients as images (clipped to what they paint), embedded images (resampled and -//! compressed as the Compression settings say) and text as outlined glyph paths. Colours are -//! converted, tagged with ICC profiles and given an output intent as the Output settings say. -//! Hidden objects, +//! compressed as the Compression settings say) and text as outlined glyph paths, or as real +//! text in embedded subset fonts (Advanced). Colours are converted, tagged with ICC profiles and +//! given an output intent as the Output settings say. Hidden objects, //! guides and template layers are skipped, and so are non-printing layers unless asked for. //! Each page is its artboard (the trim box) grown by the bleed (the bleed box) and by the //! printer's marks around it (the media box), drawn in Registration. [`PdfSettings`] is the Save PDF @@ -224,6 +224,8 @@ mod tests_presets; #[cfg(test)] mod tests_print; #[cfg(test)] +mod tests_realtext; +#[cfg(test)] mod tests_settings; #[cfg(test)] mod tests_stroke; diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index 2c445e8e..c8c5f4dc 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -342,7 +342,8 @@ pub struct OutputSettings { pub struct AdvancedSettings { /// Embed whole fonts when more than this share (%) of their characters is used. pub font_subset_percent: f64, - /// Text as glyph outlines (the only text the writer produces today). + /// Text as glyph outlines; off, text is real (selectable, searchable) text in embedded subset + /// fonts. pub outline_text: bool, pub overprint: Overprint, } @@ -438,7 +439,10 @@ impl PdfSettings { (self.thumbnails, "page thumbnails are not embedded yet"), (self.fast_web_view, "fast web view (a linearised file) is not written yet"), (self.create_layers, "PDF layers are not written yet: every layer is plain page content"), - (!self.advanced.outline_text, "text is exported as outlines: real, selectable text is not written yet"), + ( + !self.advanced.outline_text && self.advanced.font_subset_percent < 100.0, + "fonts are embedded as subsets of the characters used: a subset threshold below 100% is not applied", + ), ( !s.protected() && (s.printing != d.security.printing diff --git a/crates/pdf/src/tests_realtext.rs b/crates/pdf/src/tests_realtext.rs new file mode 100644 index 00000000..5cdcdca8 --- /dev/null +++ b/crates/pdf/src/tests_realtext.rs @@ -0,0 +1,191 @@ +//! Real text: type exported as selectable, searchable text in embedded subset fonts with a +//! ToUnicode map, where the outlines would be; fonts whose licence forbids it stay outlines. + +use serde_json::json; +use vectorcraft_color::{Color, Gradient, GradientPaint, Paint}; +use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextKind, TextObject, TextRun}; +use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect}; +use vectorcraft_text::{FALLBACK_FAMILY, FontDb}; + +use crate::*; + +fn style(size: f64) -> CharStyle { + CharStyle { size, fill: Paint::solid(Color::BLACK), ..Default::default() } +} + +/// A document holding `texts`. +fn doc(texts: Vec) -> Document { + let mut d = Document::new(400.0, 300.0); + let l = d.layers[0].id; + for (i, t) in texts.into_iter().enumerate() { + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + d.insert(Some(l), i, n).unwrap(); + } + d +} + +/// The export of `d` with real text (or outlines), content streams left readable. +fn pdf(d: &Document, outline_text: bool) -> ExportReport { + let settings: PdfSettings = + serde_json::from_value(json!({"compression": {"compressText": false}, "advanced": {"outlineText": outline_text}})).unwrap(); + export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap() +} + +fn import_as(bytes: &[u8], text_as: TextAs) -> Document { + import_with_report(bytes, &ImportOptions { text_as, ..Default::default() }).unwrap().document +} + +/// The text of the type objects of `d`. +fn texts(d: &Document) -> Vec { + let mut out = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + out.push(t.plain_text()); + } + }); + out +} + +/// The bounds of the paths of `d` (the outlines type imports as), all together. +fn ink(d: &Document) -> Rect { + let mut b: Option = None; + d.walk(|n| { + if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && let Some(r) = n.geometric_bounds() + { + b = Some(b.map_or(r, |b| b.union(r))); + } + }); + b.unwrap() +} + +#[test] +fn type_is_written_as_text_in_embedded_subset_fonts() { + let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Hello World", style(24.0))]); + let r = pdf(&d, false); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let text = String::from_utf8_lossy(&r.bytes); + assert!(text.contains("/FontFile2") || text.contains("/FontFile3"), "an embedded font program"); + assert!(text.contains("/ToUnicode") && text.contains("/Type0")); + // A subset: the font's name carries the subset tag. + assert!(text.contains(&format!("+{}", FALLBACK_FAMILY.replace(' ', ""))), "a subset"); + assert!(text.contains(" Tf") && text.contains("TJ")); + // Selectable and searchable: reopened, it is type again, spaces included. + assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), ["Hello World"]); + // Outlined text has no font. + assert!(!String::from_utf8_lossy(&pdf(&d, true).bytes).contains("/Font")); +} + +/// A curve type runs along. +fn arc() -> PathData { + let mut p = BezPath::new(); + p.move_to((20.0, 250.0)); + p.curve_to((120.0, 150.0), (260.0, 150.0), (380.0, 250.0)); + PathData::from_bezpath(&p) +} + +#[test] +fn real_text_lands_within_half_a_point_of_the_outlines() { + let plain = TextObject::point(Point::new(20.0, 60.0), "Hg fly", style(36.0)); + let mut turned = TextObject::point(Point::new(60.0, 160.0), "Wave AV", CharStyle { h_scale: 80.0, tracking: 120.0, ..style(30.0) }); + turned.xf *= Affine::rotate(-0.5) * Affine::skew(0.2, 0.0); + let mut mixed = TextObject::point(Point::new(200.0, 60.0), "", style(20.0)); + mixed.runs = vec![ + TextRun { text: "x".into(), style: style(20.0) }, + TextRun { text: "2".into(), style: CharStyle { baseline_shift: 8.0, v_scale: 140.0, ..style(12.0) } }, + TextRun { text: " ok".into(), style: CharStyle { rotation: 20.0, ..style(20.0) } }, + ]; + let mut on_path = TextObject::point(Point::ZERO, "along the arc", style(18.0)); + on_path.kind = TextKind::OnPath { path: arc(), start: 0.0 }; + on_path.xf = Affine::IDENTITY; + for t in [plain, turned, mixed, on_path] { + let what = t.plain_text(); + let d = doc(vec![t]); + let real = import_as(&pdf(&d, false).bytes, TextAs::Outlines); + let outlined = import_as(&pdf(&d, true).bytes, TextAs::Outlines); + let (a, b) = (ink(&real), ink(&outlined)); + let off = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + assert!(off < 0.5, "{what}: {a:?} vs {b:?}"); + assert_eq!(texts(&import_as(&pdf(&d, false).bytes, TextAs::Text)).concat().replace(' ', ""), what.replace(' ', ""), "{what}"); + } +} + +#[test] +fn gradients_on_real_text_stay_where_they_were() { + let fill = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut t = TextObject::point(Point::new(30.0, 80.0), "Gradient", CharStyle { fill, ..style(40.0) }); + t.xf *= Affine::rotate(0.3); + let d = doc(vec![t]); + let geom = |outline: bool| { + let back = import_as(&pdf(&d, outline).bytes, TextAs::Outlines); + let mut g = None; + back.walk(|n| { + if let Paint::Gradient(p) = n.appearance.fill_paint() { + g = g.or(p.geom); + } + }); + g.unwrap() + }; + let (real, outlined) = (geom(false), geom(true)); + assert!((real.start - outlined.start).hypot() < 0.1 && (real.end - outlined.end).hypot() < 0.1, "{real:?} vs {outlined:?}"); +} + +/// The bundled fallback face as another family `family` (13 characters, like the bundled name), +/// whose licence flags (OS/2 `fsType`) are `fs`. +fn licensed(family: &str, fs: u16) -> String { + let face = FontDb::global().face(FALLBACK_FAMILY, "Regular").unwrap(); + let mut bytes = face.file_data().to_vec(); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16(FALLBACK_FAMILY), utf16(family)), (FALLBACK_FAMILY.as_bytes().to_vec(), family.as_bytes().to_vec())] { + assert_eq!(from.len(), to.len()); + let mut at = 0; + while let Some(i) = bytes[at..].windows(from.len()).position(|w| w == from) { + bytes[at + i..at + i + from.len()].copy_from_slice(&to); + at += i + from.len(); + } + } + let tables = u16::from_be_bytes([bytes[4], bytes[5]]) as usize; + let os2 = (0..tables).map(|i| 12 + 16 * i).find(|r| &bytes[*r..*r + 4] == b"OS/2").unwrap(); + let off = u32::from_be_bytes(bytes[os2 + 8..os2 + 12].try_into().unwrap()) as usize; + bytes[off + 8..off + 10].copy_from_slice(&fs.to_be_bytes()); + FontDb::global().add_font(bytes); + assert!(FontDb::global().has_family(family)); + family.into() +} + +#[test] +fn fonts_whose_licence_forbids_embedding_fall_back_to_outlines() { + let locked = licensed("Locked Sans 3", 0x0002); + let whole = licensed("Entire Sans 3", 0x0100); + let d = doc(vec![ + TextObject::point(Point::new(20.0, 40.0), "Open", style(24.0)), + TextObject::point(Point::new(20.0, 100.0), "Shut", CharStyle { font_family: locked.clone(), ..style(24.0) }), + TextObject::point(Point::new(20.0, 160.0), "Full", CharStyle { font_family: whole.clone(), ..style(24.0) }), + ]); + let r = pdf(&d, false); + assert!(r.warnings.iter().any(|w| w.contains(&locked) && w.contains("doesn't allow embedding")), "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.contains(&whole) && w.contains("whole")), "{:?}", r.warnings); + // Only the open font is text; the others are drawn as their outlines. + let back = import_as(&r.bytes, TextAs::Text); + assert_eq!(texts(&back), ["Open"]); + let mut paths = 0; + back.walk(|n| paths += usize::from(matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }))); + assert!(paths >= 2, "{paths} outlined words"); +} + +#[test] +fn subset_thresholds_below_100_percent_are_accepted_with_a_warning() { + let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Subset", style(24.0))]); + let settings: PdfSettings = serde_json::from_value(json!({"advanced": {"outlineText": false, "fontSubsetPercent": 40}})).unwrap(); + let r = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("subset")), "{:?}", r.warnings); + assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), ["Subset"]); +} + +#[test] +fn pdf_a_takes_real_text() { + let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Archive", style(24.0))]); + let settings: PdfSettings = serde_json::from_value(json!({"standard": "pdfA2b", "advanced": {"outlineText": false}})).unwrap(); + let bytes = export(&d, &PdfOptions { settings, created: Some(0), ..Default::default() }).unwrap(); + assert_eq!(texts(&import_as(&bytes, TextAs::Text)), ["Archive"]); +} diff --git a/crates/pdf/src/tests_settings.rs b/crates/pdf/src/tests_settings.rs index cd444522..3784f0c5 100644 --- a/crates/pdf/src/tests_settings.rs +++ b/crates/pdf/src/tests_settings.rs @@ -76,7 +76,7 @@ fn options_not_applied_yet_come_back_as_warnings() { (json!({"thumbnails": true}), "thumbnails"), (json!({"fastWebView": true}), "fast web view"), (json!({"createLayers": true}), "layers"), - (json!({"advanced": {"outlineText": false}}), "outlines"), + (json!({"advanced": {"outlineText": false, "fontSubsetPercent": 35}}), "subset"), (json!({"output": {"outputIntent": "No Such Press"}}), "without embedding"), (json!({"output": {"registry": "http://example.com"}}), "condition identifier"), (json!({"standard": "pdfA2b", "output": {"trapped": true}}), "PDF/A"), @@ -86,13 +86,15 @@ fn options_not_applied_yet_come_back_as_warnings() { assert!(w.len() == 1 && w[0].contains(word), "{v}: {w:?}"); } // Bleed and marks, the default view/overprint choices and applied options (image compression - // too: a codec the writer lacks is reported when an image needs it; colour output) + // too: a codec the writer lacks is reported when an image needs it; colour output; real text) // warn about nothing. for v in [ json!({"output": {"conversion": "destination", "destination": vectorcraft_color::cms::GENERIC_CMYK, "profiles": "all"}}), json!({"output": {"conversion": "preserveNumbers", "profiles": "taggedSource"}}), json!({"output": {"outputIntent": vectorcraft_color::cms::GENERIC_CMYK, "outputCondition": "Press", "registry": "r", "trapped": true}}), json!({"output": {"outputConditionId": "CGATS TR 001"}}), + json!({"advanced": {"outlineText": false}}), + json!({"advanced": {"fontSubsetPercent": 35}}), json!({"bleed": {"useDocument": true, "top": 9}}), json!({"bleed": {"top": 9}, "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}), json!({"includeNonPrinting": true}), diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 51c63cde..63c9e2b4 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -133,6 +133,10 @@ impl FontFace { pub fn embeddable(&self) -> bool { self.fs_type() & 0x000f != 0x0002 } + /// The face's index in its font file ([`Self::file_data`]; collections hold several). + pub fn face_index(&self) -> u32 { + self.index + } } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 0c83957f..c13bd2f5 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -36,19 +36,32 @@ impl Ctx<'_> { fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) { let src = self.db.outline(&g.face, g.gid); + let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); + let m = pre * local; // Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef). let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() { BezPath::new() } else { - let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); - let m = pre * local; let mut p = BezPath::with_capacity(src.elements().len()); for el in src.elements() { p.push(m * *el); } p }; - self.out.glyphs.push(PositionedGlyph { outline, run: g.run, byte: g.byte, origin, advance, len: g.len, angle, line, font_id: g.face.id() }); + let font_id = g.face.id(); + self.out.glyphs.push(PositionedGlyph { + outline, + run: g.run, + byte: g.byte, + origin, + advance, + len: g.len, + angle, + line, + font_id, + gid: g.gid, + xf: m, + }); } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 54599838..65f51b0d 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -91,6 +91,11 @@ pub struct PositionedGlyph { pub line: usize, /// [`FontFace::id`] of the face that supplied the glyph. pub font_id: u32, + /// The glyph's id in that face. + pub gid: u32, + /// Font units (y down, as [`FontDb::outline`] gives them) → text space: where the glyph is + /// drawn, also for glyphs without an outline (spaces). + pub xf: Affine, } /// One line of laid-out text. @@ -164,6 +169,7 @@ impl TextLayout { } g.origin += shift; g.outline.apply_affine(Affine::translate(shift)); + g.xf = Affine::translate(shift) * g.xf; moved = true; } moved diff --git a/crates/ui-egui/src/tests_pdfoutput.rs b/crates/ui-egui/src/tests_pdfoutput.rs index 8dbba3ef..e4675b7a 100644 --- a/crates/ui-egui/src/tests_pdfoutput.rs +++ b/crates/ui-egui/src/tests_pdfoutput.rs @@ -1,5 +1,5 @@ -//! Save PDF › Output: the colour conversion, profiles and output intent chosen in the dialog -//! reach the written file, with nothing reported as not applied. +//! Save PDF › Output and Advanced: the colour conversion, profiles, output intent and real text +//! chosen in the dialog reach the written file, with nothing reported as not applied. use serde_json::json; use vectorcraft_color::cms::GENERIC_CMYK; @@ -16,7 +16,7 @@ fn frame(app: &mut crate::VectorcraftApp) { } #[test] -fn output_choices_reach_the_pdf() { +fn output_and_advanced_choices_reach_the_pdf() { let (mut app, written) = app("/tmp/unused.pdf"); app.run("text.create", json!({"x": 10, "y": 70, "text": "Proof"})).unwrap(); app.run("ui.savePdfDialog", json!({"path": "/tmp/press.pdf"})).unwrap(); @@ -26,6 +26,7 @@ fn output_choices_reach_the_pdf() { "output".into(), json!({"conversion": "destination", "destination": GENERIC_CMYK, "profiles": "destination", "outputIntent": GENERIC_CMYK, "outputConditionId": "Proofing", "trapped": true}), ); + fields.insert("advanced".into(), json!({"outlineText": false})); fields.insert("compression".into(), json!({"compressText": false})); frame(&mut app); // PDF/A greys the output intent out (it carries its own); a name that isn't a profile shows. @@ -38,9 +39,11 @@ fn output_choices_reach_the_pdf() { let d = app.ui.dialog.as_mut().unwrap(); d.fields.insert("standard".into(), json!("none")); d.fields["output"]["outputIntent"] = json!(GENERIC_CMYK); + d.fields.insert("__section".into(), json!("Advanced")); frame(&mut app); dialogs::confirm(&mut app).unwrap(); assert!(app.ui.status.starts_with("Saved /tmp/press.pdf") && !app.ui.status.contains("note"), "{}", app.ui.status); let pdf = String::from_utf8_lossy(&written.borrow()[0].1).into_owned(); assert!(pdf.contains("/S/GTS_PDFX") && pdf.contains("/Trapped/True") && pdf.contains("/ICCBased")); + assert!(pdf.contains("/ToUnicode"), "real text"); } diff --git a/docs/mcp.md b/docs/mcp.md index dbab8e2e..7b967443 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -799,7 +799,7 @@ the colour bars are process, spot and black-tint patches. Layers whose Print opt {"name":"run_command","arguments":{"command":"document.exportPdf","params":{"path":"/tmp/press.pdf","preset":"Press Quality","marks":{"trim":true,"registration":true,"colorBars":true,"pageInfo":true}}}} ``` -The Output section converts colours. `output: {conversion, destination}` converts +The Output and Advanced sections convert colours and write real text. `output: {conversion, destination}` converts every colour into the destination profile's model (`destination`; CMYK of another profile too) or only the colours of the other model (`preserveNumbers`: colours already in it keep their numbers); grey stays grey, images are converted too and printer's marks are not. The destination is any profile `edit.colorSettings` lists (default: the document's @@ -807,10 +807,12 @@ for its colour mode). `profiles` (`all`, `destination`, or `taggedSource` for a `edit.assignProfile`) writes colours in ICC-based spaces with their profiles embedded: CMYK with the destination's or the document's CMYK profile, RGB as sRGB, grey with the sRGB tone curve. `outputIntent` embeds a profile as the file's `/GTS_PDFX` output intent with `outputCondition`, `outputConditionId` and `registry`, and `trapped` sets `/Trapped` -(PDF/A files keep their own output intent). +(PDF/A files keep their own output intent). `advanced: {outlineText: false}` writes type as selectable, searchable +text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding stay outlines, with a +warning. ```json -{"name":"run_command","arguments":{"command":"document.exportPdf","params":{"path":"/tmp/press.pdf","output":{"conversion":"preserveNumbers","destination":"VectorCraft Generic CMYK (SWOP-like)","profiles":"destination","outputIntent":"VectorCraft Generic CMYK (SWOP-like)","trapped":true}}}} +{"name":"run_command","arguments":{"command":"document.exportPdf","params":{"path":"/tmp/press.pdf","output":{"conversion":"preserveNumbers","destination":"VectorCraft Generic CMYK (SWOP-like)","profiles":"destination","outputIntent":"VectorCraft Generic CMYK (SWOP-like)","trapped":true},"advanced":{"outlineText":false}}}} ``` ## Scale Strokes & Effects