M4.74: real text with embedded subset fonts in PDF

With Convert Text to Outlines off (advanced.outlineText: false), type is
written as selectable, searchable text: each glyph in its font, embedded
as a subset with a ToUnicode map, where its outline would be (rotated,
scaled, skewed and on-path type too; gradients keep their place). Fonts
whose licence forbids embedding, or allows only whole fonts, fall back to
outlines with a warning; a subset threshold below 100% is accepted with a
warning. Laid-out glyphs now carry their glyph id and transform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 16:25:18 +07:00
parent 5b69862a08
commit a5367b0846
12 changed files with 412 additions and 27 deletions

View File

@ -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<CommandSpec> {
"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
),

View File

@ -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);

View File

@ -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<ColorOut>,
/// 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<u32, Option<(krilla::text::Font, f64)>>,
}
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<usize>)>,
}
/// 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<krilla::paint::Paint> {
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<krilla::paint::Paint> {
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<GlyphLine> = 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<KrillaGlyph> = 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).

View File

@ -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;

View File

@ -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

View File

@ -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<TextObject>) -> 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<String> {
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<Rect> = 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::<Vec<u8>>();
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"]);
}

View File

@ -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}),

View File

@ -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"))]

View File

@ -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,
});
}
}

View File

@ -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

View File

@ -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");
}

View File

@ -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