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:
parent
5b69862a08
commit
a5367b0846
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
}
|
||||
|
|
@ -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}),
|
||||
|
|
|
|||
|
|
@ -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"))]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue