mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #115 from storytold/pr/m4-pdf-color-text
M4.73–M4.74: PDF colour conversion, profiles and real text
This commit is contained in:
22 files changed
+1640
-140
No files matched your search
Generated
+1
@@ -4397,6 +4397,7 @@ name = "vectorcraft-pdf"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
"image",
|
||||
|
||||
@@ -893,6 +893,8 @@ mod tests_dxfimport;
|
||||
#[cfg(test)]
|
||||
mod tests_metafile;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdfoutput;
|
||||
#[cfg(test)]
|
||||
mod tests_tiffbmp;
|
||||
|
||||
|
||||
@@ -46,8 +46,18 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
default: "null",
|
||||
description: "{useDocument, top, bottom, left, right} in points: the page grows by the bleed (BleedBox) around its artboard (TrimBox)",
|
||||
},
|
||||
FormatOption { name: "output", ty: "object", default: "null", description: "colour conversion and output intent (see document.exportPdf)" },
|
||||
FormatOption { name: "advanced", ty: "object", default: "null", description: "{fontSubsetPercent, outlineText, overprint}" },
|
||||
FormatOption {
|
||||
name: "output",
|
||||
ty: "object",
|
||||
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 (false: real text in embedded subset fonts), overprint}",
|
||||
},
|
||||
FormatOption {
|
||||
name: "security",
|
||||
ty: "object",
|
||||
@@ -69,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|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, 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
|
||||
),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! `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};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn pdf(s: &mut Session, p: Value) -> (String, Value) {
|
||||
let v = s.execute("document.exportPdf", &p).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
(String::from_utf8_lossy(&bytes).into_owned(), v["warnings"].clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
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();
|
||||
s.execute("paint.setFill", &json!({"color": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 80, "y": 60, "text": "Press ready"})).unwrap();
|
||||
let generic = vectorcraft_color::cms::GENERIC_CMYK;
|
||||
let p = json!({
|
||||
"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);
|
||||
assert_eq!(s.execute("document.pdfSettings", &q).unwrap()["warnings"], json!([]));
|
||||
// Converting to a profile that isn't there is a bad parameter.
|
||||
let bad = s.execute("document.exportPdf", &json!({"output": {"conversion": "destination", "destination": "Missing"}}));
|
||||
assert!(matches!(bad, Err(crate::EngineError::BadParams { .. })), "{bad:?}");
|
||||
}
|
||||
@@ -17,6 +17,7 @@ vectorcraft-color = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
image = { workspace = true }
|
||||
krilla = { workspace = true }
|
||||
hayro-syntax = "0.7.2"
|
||||
|
||||
+262
-41
@@ -1,6 +1,8 @@
|
||||
//! Document → PDF (krilla). Mirrors the tree walk of `vectorcraft-render`.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
|
||||
use krilla::color::{cmyk, luma, rgb};
|
||||
@@ -12,14 +14,18 @@ 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;
|
||||
use crate::output::ColorOut;
|
||||
use crate::{Compatibility, ExportReport, PdfError, PdfOptions, Standard};
|
||||
|
||||
/// Export `doc` as PDF bytes: one page per artboard (or the artboards chosen in `opts`).
|
||||
@@ -63,6 +69,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
|
||||
|
||||
let mut ex = Exporter::new(doc, set);
|
||||
ex.non_printing = set.include_non_printing || set.create_layers;
|
||||
ex.out = w.out.clone();
|
||||
// Marks and Bleeds: each page is its artboard (the trim box) grown by the bleed, and by the
|
||||
// printer's marks around that.
|
||||
let bleed = set.bleed_of(doc);
|
||||
@@ -102,7 +109,10 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
|
||||
/// its pages ([`Self::page`]), each drawn by an [`Exporter`].
|
||||
pub(crate) struct Writer {
|
||||
pub pdf: krilla::Document,
|
||||
/// Groups blend in CMYK (a CMYK document's composite, see `cmyk_blending`).
|
||||
/// How colours are written (the Output settings).
|
||||
pub out: Arc<ColorOut>,
|
||||
/// Groups blend in CMYK (a CMYK document's composite, or converting to CMYK; see
|
||||
/// `cmyk_blending`).
|
||||
cmyk: bool,
|
||||
/// Spot colours written with a Lab alternate, gathered from the exporters ([`Self::absorb`]).
|
||||
lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>,
|
||||
@@ -134,9 +144,17 @@ pub(crate) struct Sheet<'a> {
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
/// A PDF with `set`'s version, standard and compression, and `doc`'s metadata under `title`,
|
||||
/// created at `created` (Unix seconds). With `cmyk`, transparency blends in CMYK.
|
||||
/// A PDF with `set`'s version, standard, compression and colour output, and `doc`'s metadata
|
||||
/// under `title`, created at `created` (Unix seconds). With `cmyk`, transparency blends in
|
||||
/// CMYK (unless the colours are converted to RGB).
|
||||
pub(crate) fn new(doc: &Document, set: &crate::PdfSettings, title: &str, created: Option<i64>, cmyk: bool) -> Result<Self, PdfError> {
|
||||
let out = ColorOut::new(doc, set)?;
|
||||
let mut warnings = vec![];
|
||||
// Tagged CMYK colours need the profile (PDF/A tags every colour).
|
||||
let cmyk_profile = if out.tagged { out.cmyk_icc() } else { None };
|
||||
if out.tagged && cmyk_profile.is_none() {
|
||||
warnings.push(format!("the CMYK profile {} can't be embedded: CMYK colours are written untagged", out.cmyk_profile()));
|
||||
}
|
||||
let version = match set.compatibility {
|
||||
Compatibility::Pdf14 => PdfVersion::Pdf14,
|
||||
Compatibility::Pdf15 => PdfVersion::Pdf15,
|
||||
@@ -150,7 +168,13 @@ impl Writer {
|
||||
cb = cb.with_archival_validator(Archival::A2_B);
|
||||
}
|
||||
let configuration = cb.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
|
||||
let settings = krilla::SerializeSettings { compress_content_streams: set.compression.compress_text, configuration, ..Default::default() };
|
||||
let settings = krilla::SerializeSettings {
|
||||
compress_content_streams: set.compression.compress_text,
|
||||
no_device_cs: out.tagged,
|
||||
cmyk_profile,
|
||||
configuration,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut pdf = krilla::Document::new_with(settings);
|
||||
let mut meta = Metadata::new().creator("VectorCraft".into()).producer("VectorCraft".into());
|
||||
@@ -173,7 +197,7 @@ impl Writer {
|
||||
meta = meta.creation_date(t);
|
||||
}
|
||||
pdf.set_metadata(meta);
|
||||
Ok(Self { pdf, cmyk, lab_spots: vec![], warnings: vec![] })
|
||||
Ok(Self { pdf, cmyk: out.blends_cmyk(cmyk), out: Arc::new(out), lab_spots: vec![], warnings })
|
||||
}
|
||||
|
||||
/// Draw one page: the art of `ex`'s document placed as `sheet` says, then its marks.
|
||||
@@ -227,8 +251,10 @@ impl Writer {
|
||||
// The drawing space the page shows (the marks' area).
|
||||
let shown = sheet.view.inverse().transform_rect_bbox(paper);
|
||||
let knockout = std::mem::replace(&mut ex.knockout, false);
|
||||
// Marks keep their inks: they aren't converted to the destination.
|
||||
let convert = std::mem::replace(&mut ex.convert, false);
|
||||
ex.node(&mut s, art, sheet.window.map_or(shown, |r| r.intersect(shown)), true);
|
||||
ex.knockout = knockout;
|
||||
(ex.knockout, ex.convert) = (knockout, convert);
|
||||
}
|
||||
if window.is_some() {
|
||||
s.pop();
|
||||
@@ -258,44 +284,80 @@ impl Writer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The finished file and the warnings of its drawing.
|
||||
pub(crate) fn finish(self) -> Result<(Vec<u8>, Vec<String>), PdfError> {
|
||||
/// The finished file (with the output intent and Trapped entries) and the warnings of its
|
||||
/// drawing.
|
||||
pub(crate) fn finish(mut self) -> Result<(Vec<u8>, Vec<String>), PdfError> {
|
||||
let mut bytes = self.pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
|
||||
if self.cmyk {
|
||||
cmyk_blending(&mut bytes);
|
||||
cmyk_blending(&mut bytes, self.out.tagged);
|
||||
}
|
||||
let bytes = if self.lab_spots.is_empty() { bytes } else { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) };
|
||||
let (bytes, more) = self.out.write_catalog(bytes)?;
|
||||
self.warnings.extend(more);
|
||||
Ok((bytes, self.warnings))
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the transparency groups of `pdf` blend in DeviceCMYK. The PDF writer gives every group
|
||||
/// DeviceRGB as its blending space, so the group dictionaries are rewritten in place, keeping their
|
||||
/// length so the cross-reference offsets stay valid. Luminosity masks' groups keep RGB: mask
|
||||
/// luminance is that of screen colours, as on screen.
|
||||
fn cmyk_blending(pdf: &mut [u8]) {
|
||||
/// Make the transparency groups of `pdf` blend in CMYK. The PDF writer gives every group RGB as
|
||||
/// its blending space (DeviceRGB, or sRGB when colours are `tagged`), so the group dictionaries
|
||||
/// are rewritten in place to DeviceCMYK (or the CMYK profile's space), keeping their length so the
|
||||
/// cross-reference offsets stay valid. Luminosity masks' groups keep RGB: mask luminance is that
|
||||
/// of screen colours, as on screen.
|
||||
fn cmyk_blending(pdf: &mut [u8], tagged: bool) {
|
||||
let masks = luminosity_mask_groups(pdf);
|
||||
let mut spaces = vec![("/CS/DeviceRGB".to_string(), "/CS/DeviceCMYK".to_string())];
|
||||
if tagged {
|
||||
spaces.extend(icc_spaces(pdf));
|
||||
}
|
||||
let mut from = 0;
|
||||
while let Some(at) = find(pdf, b"/Group<<", from).map(|i| i + b"/Group".len()) {
|
||||
let end = find(pdf, b">>", at).map_or(pdf.len(), |i| i + 2);
|
||||
if !object_number(&pdf[..at]).is_some_and(|n| masks.contains(&n)) {
|
||||
cmyk_group(&mut pdf[at..end]);
|
||||
let mask = pdf.get(..at).and_then(object_number).is_some_and(|n| masks.contains(&n));
|
||||
if let (false, Some(dict)) = (mask, pdf.get_mut(at..end)) {
|
||||
cmyk_group(dict, &spaces);
|
||||
}
|
||||
from = end;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite transparency group dictionary `dict` (`<<…>>`) to blend in DeviceCMYK, at the same
|
||||
/// length: leaving out the optional `/Type/Group` makes room for the longer name, spaces pad the
|
||||
/// rest.
|
||||
fn cmyk_group(dict: &mut [u8]) {
|
||||
/// The ICC-based RGB colour spaces of `pdf`, each with the ICC-based CMYK one to blend in instead,
|
||||
/// as group `/CS` entries (`/CS 3 0 R`); none without a CMYK one.
|
||||
fn icc_spaces(pdf: &[u8]) -> Vec<(String, String)> {
|
||||
const ICC: &[u8] = b"[/ICCBased ";
|
||||
let (mut rgb, mut cmyk) = (vec![], None);
|
||||
let mut from = 0;
|
||||
while let Some(at) = find(pdf, ICC, from) {
|
||||
from = at + ICC.len();
|
||||
let digits = pdf.get(from..).map_or(0, |r| r.iter().take_while(|b| b.is_ascii_digit()).count());
|
||||
let (Some(array), Some(stream)) = (pdf.get(..at).and_then(object_number), pdf.get(from..from + digits).and_then(number)) else {
|
||||
continue;
|
||||
};
|
||||
// The profile stream's dictionary says how many components it has.
|
||||
let Some(head) = find(pdf, format!("\n{stream} 0 obj").as_bytes(), 0) else { continue };
|
||||
let dict = pdf.get(head..find(pdf, b"stream", head).unwrap_or(pdf.len())).unwrap_or_default();
|
||||
if find(dict, b"/N 4", 0).is_some() {
|
||||
cmyk.get_or_insert(format!("/CS {array} 0 R"));
|
||||
} else if find(dict, b"/N 3", 0).is_some() {
|
||||
rgb.push(format!("/CS {array} 0 R"));
|
||||
}
|
||||
}
|
||||
let Some(cmyk) = cmyk else { return vec![] };
|
||||
rgb.into_iter().map(|r| (r, cmyk.clone())).collect()
|
||||
}
|
||||
|
||||
/// Rewrite transparency group dictionary `dict` (`<<…>>`) to blend in the CMYK space of the first
|
||||
/// of `spaces` (RGB `/CS` entry, CMYK one) it has, at the same length: leaving out the optional
|
||||
/// `/Type/Group` makes room for the longer entry, spaces pad the rest.
|
||||
fn cmyk_group(dict: &mut [u8], spaces: &[(String, String)]) {
|
||||
let Ok(text) = std::str::from_utf8(dict) else { return };
|
||||
if !(text.contains("/S/Transparency") && text.contains("/Type/Group") && text.contains("/CS/DeviceRGB")) {
|
||||
if !(text.contains("/S/Transparency") && text.contains("/Type/Group")) {
|
||||
return;
|
||||
}
|
||||
let body = text.replacen("/Type/Group", "", 1).replacen("/CS/DeviceRGB", "/CS/DeviceCMYK", 1);
|
||||
let Some((rgb, cmyk)) = spaces.iter().find(|(rgb, _)| text.contains(rgb.as_str())) else { return };
|
||||
let body = text.replacen("/Type/Group", "", 1).replacen(rgb.as_str(), cmyk, 1);
|
||||
let body = body.strip_suffix(">>").unwrap_or(&body);
|
||||
let new = format!("{body}{}>>", " ".repeat(dict.len() - body.len() - 2));
|
||||
let Some(pad) = dict.len().checked_sub(body.len() + 2) else { return };
|
||||
let new = format!("{body}{}>>", " ".repeat(pad));
|
||||
dict.copy_from_slice(new.as_bytes());
|
||||
}
|
||||
|
||||
@@ -361,6 +423,15 @@ pub(crate) struct Exporter<'a> {
|
||||
pub intent: vectorcraft_color::cms::Intent,
|
||||
/// Whether a layer shows transparency (found out on the first page that asks).
|
||||
transparent: Option<bool>,
|
||||
/// How colours are written (the Output settings; print writes them as they are).
|
||||
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> {
|
||||
@@ -379,6 +450,10 @@ impl<'a> Exporter<'a> {
|
||||
non_printing: false,
|
||||
intent: vectorcraft_color::cms::active().settings().intent,
|
||||
transparent: None,
|
||||
out: Arc::default(),
|
||||
convert: true,
|
||||
outline_text: set.advanced.outline_text,
|
||||
fonts: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,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;
|
||||
|
||||
@@ -485,14 +572,17 @@ fn rects_overlap(a: Rect, b: Rect) -> bool {
|
||||
}
|
||||
|
||||
impl Exporter<'_> {
|
||||
/// A colour for the page: in CMYK documents RGB and Lab colours are separated into DeviceCMYK
|
||||
/// through the active colour settings, so the file carries press values.
|
||||
/// A colour for the page, as the Output settings write it ([`ColorOut::color`]): without
|
||||
/// conversion, RGB and Lab colours of CMYK documents are separated into CMYK through the
|
||||
/// active colour settings, so the file carries press values.
|
||||
fn col(&mut self, c: &Color) -> krilla::color::Color {
|
||||
if self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk && matches!(c, Color::Rgb { .. } | Color::Lab { .. }) {
|
||||
let [cc, m, y, k] = vectorcraft_color::cms::active().to_cmyk(c, self.intent);
|
||||
return cmyk::Color::new(q(cc), q(m), q(y), q(k)).into();
|
||||
let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
|
||||
let c = self.out.color(c, self.intent, cmyk_doc, self.convert);
|
||||
if let (Color::Rgb { .. }, Some(note)) = (c, &self.out.srgb_note) {
|
||||
let note = note.clone();
|
||||
self.warn(note);
|
||||
}
|
||||
color(c)
|
||||
color(&c)
|
||||
}
|
||||
|
||||
/// The Separation colour space of spot swatch `name` (its CMYK equivalent is the alternate
|
||||
@@ -507,7 +597,7 @@ impl Exporter<'_> {
|
||||
{
|
||||
self.lab_spots.push((name.to_string(), vectorcraft_color::cms::Lab::new(l, a, b)));
|
||||
}
|
||||
let full = vectorcraft_color::cms::active().to_cmyk(&color, self.intent);
|
||||
let full = self.out.cmyk(&color, self.intent);
|
||||
let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3])));
|
||||
Some(SeparationSpace::new(SeparationColorant::Custom(sw.name.clone()), alt))
|
||||
}
|
||||
@@ -578,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()),
|
||||
@@ -623,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,
|
||||
@@ -645,7 +741,7 @@ impl Exporter<'_> {
|
||||
cx,
|
||||
cy,
|
||||
cr: r,
|
||||
transform: xf(t),
|
||||
transform: xf(space * t),
|
||||
spread_method: SpreadMethod::Pad,
|
||||
stops,
|
||||
anti_alias: false,
|
||||
@@ -665,7 +761,7 @@ impl Exporter<'_> {
|
||||
acc[i] += c[i] / n;
|
||||
}
|
||||
}
|
||||
Some(color(&Color::rgb(acc[0], acc[1], acc[2])).into())
|
||||
Some(self.col(&Color::rgb(acc[0], acc[1], acc[2])).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -958,9 +1054,11 @@ impl Exporter<'_> {
|
||||
let (cols, rows) = (along(area.width()), along(area.height()));
|
||||
let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb());
|
||||
let rgba: Vec<u8> = field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect();
|
||||
let (Some(img), Some(size)) =
|
||||
(crate::images::from_rgba(&rgba, cols.into(), rows.into(), self.interpolate), Size::from_wh(area.width() as f32, area.height() as f32))
|
||||
else {
|
||||
let pixels = if self.convert { self.out.pixels() } else { None };
|
||||
let (Some(img), Some(size)) = (
|
||||
crate::images::from_rgba_in(rgba, cols.into(), rows.into(), self.interpolate, pixels),
|
||||
Size::from_wh(area.width() as f32, area.height() as f32),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
s.push_transform(&xf(Affine::translate(area.origin().to_vec2())));
|
||||
@@ -1125,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);
|
||||
}
|
||||
@@ -1175,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).
|
||||
@@ -1187,8 +1407,9 @@ impl Exporter<'_> {
|
||||
}
|
||||
let interpolate = self.interpolate;
|
||||
let doc = self.doc;
|
||||
let out = self.out.clone();
|
||||
let img = doc.images.get(key).and_then(|blob| {
|
||||
let r = crate::images::recode(&blob.bytes, size, c, interpolate);
|
||||
let r = crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels());
|
||||
if let Some(w) = r.warning {
|
||||
self.warn(w);
|
||||
}
|
||||
|
||||
+55
-17
@@ -1,6 +1,6 @@
|
||||
//! Images in the PDF: the document's images as the Compression settings say (resampled above a
|
||||
//! resolution, compressed with ZIP or JPEG), and pixels the writer makes itself (freeform
|
||||
//! gradients).
|
||||
//! resolution, compressed with ZIP or JPEG) and converted as the Output settings say
|
||||
//! ([`Pixels`]), and pixels the writer makes itself (freeform gradients).
|
||||
//!
|
||||
//! Every image is classified as the settings are split: colour, greyscale (a grey colour type) or
|
||||
//! monochrome (greyscale that is only black and white). An image already as asked (not resampled,
|
||||
@@ -10,20 +10,22 @@
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use image::buffer::ConvertBuffer;
|
||||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageReader};
|
||||
use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageReader, RgbaImage};
|
||||
use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace};
|
||||
|
||||
use crate::output::Pixels;
|
||||
use crate::{CompressionSettings, Downsample, ImageCodec, JpegQuality, MonoCodec};
|
||||
|
||||
/// Raw 8-bit pixels: colour samples (RGB, or grey with `luma`) and an optional alpha channel.
|
||||
/// Raw 8-bit pixels: colour samples (RGB, grey or CMYK) and an optional alpha channel.
|
||||
#[derive(Clone, Hash)]
|
||||
struct Raw {
|
||||
color: Arc<Vec<u8>>,
|
||||
alpha: Option<Arc<Vec<u8>>>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
luma: bool,
|
||||
space: ImageColorspace,
|
||||
}
|
||||
|
||||
impl CustomImage for Raw {
|
||||
@@ -48,7 +50,7 @@ impl CustomImage for Raw {
|
||||
}
|
||||
|
||||
fn color_space(&self) -> ImageColorspace {
|
||||
if self.luma { ImageColorspace::Luma } else { ImageColorspace::Rgb }
|
||||
self.space
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,14 +59,34 @@ impl CustomImage for Raw {
|
||||
/// `interpolate` asks viewers to smooth it when enlarged (PDF/A forbids it). `None` when `rgba`
|
||||
/// doesn't hold that many pixels.
|
||||
pub(crate) fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool) -> Option<Image> {
|
||||
from_rgba_as(rgba, width, height, interpolate, None)
|
||||
}
|
||||
|
||||
/// [`from_rgba`], colour pixels written as CMYK when `pixels` converts to CMYK (grey ones stay
|
||||
/// grey).
|
||||
fn from_rgba_as(rgba: &[u8], width: u32, height: u32, interpolate: bool, pixels: Option<&Pixels>) -> Option<Image> {
|
||||
let px = rgba.as_chunks::<4>().0;
|
||||
if px.len() as u64 != width as u64 * height as u64 || px.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let luma = px.iter().all(|p| p[0] == p[1] && p[1] == p[2]);
|
||||
let color = if luma { px.iter().map(|p| p[0]).collect() } else { px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect() };
|
||||
let (color, space) = if luma {
|
||||
(px.iter().map(|p| p[0]).collect(), ImageColorspace::Luma)
|
||||
} else if let Some(cmyk) = pixels.and_then(|p| p.cmyk(rgba)) {
|
||||
(cmyk, ImageColorspace::Cmyk)
|
||||
} else {
|
||||
(px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(), ImageColorspace::Rgb)
|
||||
};
|
||||
let alpha = px.iter().any(|p| p[3] < 255).then(|| Arc::new(px.iter().map(|p| p[3]).collect()));
|
||||
Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, luma }, interpolate).ok()
|
||||
Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, space }, interpolate).ok()
|
||||
}
|
||||
|
||||
/// [`from_rgba`] of pixels converted by `pixels` (when given).
|
||||
pub(crate) fn from_rgba_in(mut rgba: Vec<u8>, width: u32, height: u32, interpolate: bool, pixels: Option<&Pixels>) -> Option<Image> {
|
||||
if let Some(p) = pixels {
|
||||
p.rgb_in_place(&mut rgba);
|
||||
}
|
||||
from_rgba_as(&rgba, width, height, interpolate, pixels)
|
||||
}
|
||||
|
||||
/// The kinds of images the Compression section has settings for.
|
||||
@@ -96,6 +118,7 @@ struct Rule {
|
||||
}
|
||||
|
||||
const UNCOMPRESSED: &str = "uncompressed images are not written: images are compressed with ZIP";
|
||||
const CMYK_ZIP: &str = "images converted to CMYK are compressed with ZIP, not JPEG";
|
||||
|
||||
/// JPEG quality (1–100) of a quality setting.
|
||||
fn quality(q: JpegQuality) -> u8 {
|
||||
@@ -144,9 +167,10 @@ fn bilevel(img: &DynamicImage) -> bool {
|
||||
}
|
||||
|
||||
/// Encoded image `bytes` placed `size` points wide and high, as the Compression settings `c` ask:
|
||||
/// resampled when its resolution is above the threshold, compressed with ZIP or JPEG.
|
||||
/// `interpolate` lets viewers smooth it. An image that can't be decoded is kept as it is.
|
||||
pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, interpolate: bool) -> Recoded {
|
||||
/// resampled when its resolution is above the threshold, compressed with ZIP or JPEG, its colours
|
||||
/// converted by `convert` (colour images). `interpolate` lets viewers smooth it. An image that
|
||||
/// can't be decoded is kept as it is.
|
||||
pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, interpolate: bool, convert: Option<&Pixels>) -> Recoded {
|
||||
let keep = Recoded { image: None, warning: None };
|
||||
let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() else { return keep };
|
||||
let jpeg = reader.format() == Some(ImageFormat::Jpeg);
|
||||
@@ -183,7 +207,8 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in
|
||||
(Kind::Color, None)
|
||||
};
|
||||
let rule = rule(c, kind);
|
||||
if kept(&rule) {
|
||||
let convert = convert.filter(|_| kind == Kind::Color);
|
||||
if kept(&rule) && convert.is_none() {
|
||||
return Recoded { image: None, warning: rule.instead };
|
||||
}
|
||||
let Some(img) = pixels.or_else(|| image::load_from_memory(bytes).ok()) else { return keep };
|
||||
@@ -210,14 +235,27 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in
|
||||
Codec::Auto(q) if jpeg => Some(q),
|
||||
_ => None,
|
||||
};
|
||||
let image = quality.filter(|_| rgba.pixels().all(|p| p[3] == 255)).and_then(|q| to_jpeg(&img, kind != Kind::Color, q, interpolate));
|
||||
// CMYK images are written losslessly (the PDF writer's CMYK JPEGs need the reader's
|
||||
// conventions).
|
||||
let cmyk = convert.is_some_and(|p| p.is_cmyk());
|
||||
let (width, height) = rgba.dimensions();
|
||||
Recoded { image: image.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate)), warning: rule.instead }
|
||||
let opaque = rgba.pixels().all(|p| p[3] == 255);
|
||||
let image = match quality.filter(|_| opaque && !cmyk) {
|
||||
Some(q) => {
|
||||
if let Some(p) = convert {
|
||||
p.rgb_in_place(&mut rgba);
|
||||
}
|
||||
to_jpeg(&rgba, kind != Kind::Color, q, interpolate).or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate))
|
||||
}
|
||||
None => from_rgba_in(rgba.into_raw(), width, height, interpolate, convert),
|
||||
};
|
||||
let warning = rule.instead.or((cmyk && quality.is_some() && opaque).then_some(CMYK_ZIP));
|
||||
Recoded { image, warning }
|
||||
}
|
||||
|
||||
/// Opaque `img` as a JPEG image at `quality` (a greyscale JPEG with `grey`).
|
||||
fn to_jpeg(img: &DynamicImage, grey: bool, quality: u8, interpolate: bool) -> Option<Image> {
|
||||
let img = if grey { DynamicImage::ImageLuma8(img.to_luma8()) } else { DynamicImage::ImageRgb8(img.to_rgb8()) };
|
||||
/// Opaque `rgba` as a JPEG image at `quality` (a greyscale JPEG with `grey`).
|
||||
fn to_jpeg(rgba: &RgbaImage, grey: bool, quality: u8, interpolate: bool) -> Option<Image> {
|
||||
let img = if grey { DynamicImage::ImageLuma8(rgba.convert()) } else { DynamicImage::ImageRgb8(rgba.convert()) };
|
||||
let mut out = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality).encode_image(&img).ok()?;
|
||||
Image::from_jpeg(out.into(), interpolate).ok()
|
||||
|
||||
+11
-42
@@ -9,6 +9,8 @@
|
||||
use vectorcraft_color::cms::Lab;
|
||||
use vectorcraft_color::cms::lab::D50;
|
||||
|
||||
use crate::patch::{Patch, Xref};
|
||||
|
||||
/// `name` as the PDF writer writes a name object (`/` then the bytes, irregular ones as `#XX`).
|
||||
fn pdf_name(name: &str) -> Vec<u8> {
|
||||
let mut out = vec![b'/'];
|
||||
@@ -53,8 +55,9 @@ pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
||||
/// written by the export: uncompressed objects, a cross-reference table) to Lab alternates.
|
||||
/// Spaces written another way are left as they are.
|
||||
pub(crate) fn lab_alternates(pdf: Vec<u8>, spots: &[(String, Lab)]) -> Vec<u8> {
|
||||
// (start, end, replacement) of each rewritten array, in file order.
|
||||
let mut edits: Vec<(usize, usize, Vec<u8>)> = vec![];
|
||||
let Some(xref) = Xref::read(&pdf) else { return pdf };
|
||||
let mut patch = Patch::new(&xref);
|
||||
let mut any = false;
|
||||
for (name, lab) in spots {
|
||||
let mut head = b"[/Separation".to_vec();
|
||||
head.extend(pdf_name(name));
|
||||
@@ -62,52 +65,18 @@ pub(crate) fn lab_alternates(pdf: Vec<u8>, spots: &[(String, Lab)]) -> Vec<u8> {
|
||||
let mut from = 0;
|
||||
while let Some(start) = find(&pdf, &head, from) {
|
||||
let Some(end) = find(&pdf, b">>]", start).map(|e| e + 3) else { break };
|
||||
edits.push((start, end, lab_separation(name, *lab)));
|
||||
patch.replace(start, end, lab_separation(name, *lab));
|
||||
any = true;
|
||||
from = end;
|
||||
}
|
||||
}
|
||||
let Some(xref) = xref_offset(&pdf) else { return pdf };
|
||||
if edits.is_empty() || edits.iter().any(|e| e.1 > xref) {
|
||||
if !any {
|
||||
return pdf;
|
||||
}
|
||||
edits.sort_by_key(|e| e.0);
|
||||
// Where a byte offset of the old file lands in the new one.
|
||||
let moved = |off: usize| -> usize {
|
||||
edits.iter().filter(|e| e.1 <= off).fold(off as isize, |o, e| o + e.2.len() as isize - (e.1 - e.0) as isize) as usize
|
||||
};
|
||||
let mut out = Vec::with_capacity(pdf.len() + 256 * edits.len());
|
||||
let mut at = 0;
|
||||
for (start, end, new) in &edits {
|
||||
out.extend_from_slice(&pdf[at..*start]);
|
||||
out.extend_from_slice(new);
|
||||
at = *end;
|
||||
match patch.apply(&pdf, &xref) {
|
||||
Ok(out) => out,
|
||||
Err(_) => pdf,
|
||||
}
|
||||
out.extend_from_slice(&pdf[at..xref]);
|
||||
// The cross-reference table: `xref\n0 N\n` then N 20-byte entries; in-use ones hold offsets.
|
||||
let tail = &pdf[xref..];
|
||||
let Some(entries) = find(tail, b"\n", 5).map(|i| i + 1) else { return pdf };
|
||||
let Some(count) = std::str::from_utf8(&tail[5..entries - 1]).ok().and_then(|s| s.split_whitespace().nth(1)?.parse::<usize>().ok()) else {
|
||||
return pdf;
|
||||
};
|
||||
let table_end = entries + 20 * count;
|
||||
if tail.len() < table_end {
|
||||
return pdf;
|
||||
}
|
||||
out.extend_from_slice(&tail[..entries]);
|
||||
for e in tail[entries..table_end].chunks(20) {
|
||||
match std::str::from_utf8(&e[..10]).ok().and_then(|s| s.parse::<usize>().ok()) {
|
||||
Some(off) if e[17] == b'n' => {
|
||||
out.extend(format!("{:010}", moved(off)).bytes());
|
||||
out.extend_from_slice(&e[10..]);
|
||||
}
|
||||
_ => out.extend_from_slice(e),
|
||||
}
|
||||
}
|
||||
let rest = &tail[table_end..];
|
||||
let Some(sx) = rfind(rest, b"startxref\n") else { return pdf };
|
||||
out.extend_from_slice(&rest[..sx]);
|
||||
out.extend(format!("startxref\n{}\n%%EOF", moved(xref)).bytes());
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn rfind(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
|
||||
@@ -6,7 +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. 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
|
||||
@@ -45,7 +47,9 @@ mod import_shading;
|
||||
mod import_text;
|
||||
mod lab_spot;
|
||||
mod marks;
|
||||
mod output;
|
||||
mod pages;
|
||||
mod patch;
|
||||
mod presets;
|
||||
mod print;
|
||||
mod settings;
|
||||
@@ -214,10 +218,14 @@ mod tests_import_options;
|
||||
#[cfg(test)]
|
||||
mod tests_marks;
|
||||
#[cfg(test)]
|
||||
mod tests_output;
|
||||
#[cfg(test)]
|
||||
mod tests_presets;
|
||||
#[cfg(test)]
|
||||
mod tests_print;
|
||||
#[cfg(test)]
|
||||
mod tests_realtext;
|
||||
#[cfg(test)]
|
||||
mod tests_settings;
|
||||
#[cfg(test)]
|
||||
mod tests_stroke;
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
//! Save PDF › Output: colours converted to a destination profile, the ICC profiles that tag them,
|
||||
//! and the output intent and Trapped entries.
|
||||
//!
|
||||
//! - Conversion ([`ColorConversion`]): `destination` converts every colour to the destination
|
||||
//! profile's model (CMYK or RGB) with the colour settings' intent, also colours already in that
|
||||
//! model when their profile differs from the destination; `preserveNumbers` converts only the
|
||||
//! colours of the other model (and Lab ones), keeping the numbers of those already in it.
|
||||
//! Grey stays grey. Images (and freeform gradients' images) are converted the same way, pixel
|
||||
//! by pixel; printer's marks are not. The destination is a profile name; empty, the document's
|
||||
//! profile for its colour mode.
|
||||
//! - Profiles ([`ProfileInclusion`]): with `all`, with `destination`, and with `taggedSource` on a
|
||||
//! document that was assigned profiles, colours are written in ICC-based colour spaces: CMYK
|
||||
//! with the CMYK profile in effect (the destination when converting to CMYK, else the
|
||||
//! document's), RGB with sRGB (colours of another RGB space as their sRGB equivalents) and grey
|
||||
//! with the sRGB tone curve. Otherwise they are device colours. PDF/A always tags them.
|
||||
//! - Output intent: the profile `outputIntent` names, embedded as the `/GTS_PDFX` output intent
|
||||
//! of the catalog with the condition, identifier and registry; Trapped goes in the document
|
||||
//! information (`/False` when an output intent is written untrapped). PDF/A files carry their
|
||||
//! own output intent: neither is written there.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_color::cms::{self, Cms, Intent, Model, ProfileKind, ProofLut};
|
||||
use vectorcraft_doc::{ColorMode, Document};
|
||||
|
||||
use crate::patch::{Patch, Xref};
|
||||
use crate::{ColorConversion, PdfError, PdfSettings, ProfileInclusion, Standard};
|
||||
|
||||
/// Most characters of an output intent's text entries written.
|
||||
const MAX_TEXT: usize = 255;
|
||||
|
||||
/// The destination colours are converted to.
|
||||
struct Dest {
|
||||
cms: Cms,
|
||||
model: Model,
|
||||
/// Colours already in the destination's model keep their numbers (Preserve Numbers, or the
|
||||
/// destination is their own profile).
|
||||
keep: bool,
|
||||
/// The destination is sRGB (what images are).
|
||||
srgb: bool,
|
||||
}
|
||||
|
||||
/// How an export writes colours (see the module documentation).
|
||||
pub(crate) struct ColorOut {
|
||||
/// The colour settings colours come from.
|
||||
source: Arc<Cms>,
|
||||
dest: Option<Dest>,
|
||||
/// Colours are written in ICC-based colour spaces.
|
||||
pub tagged: bool,
|
||||
/// The CMYK profile that tags CMYK colours.
|
||||
cmyk_profile: String,
|
||||
/// Tagged RGB colours are written as their sRGB equivalents: why (a warning), when their RGB
|
||||
/// space isn't sRGB.
|
||||
pub srgb_note: Option<String>,
|
||||
/// The image transform, built when the first image needs it.
|
||||
pixels: OnceLock<Option<Pixels>>,
|
||||
intent: Intent,
|
||||
/// The output intent and Trapped entries to write.
|
||||
catalog: Option<Catalog>,
|
||||
}
|
||||
|
||||
/// An output intent dictionary's entries.
|
||||
struct OutputIntent {
|
||||
/// `/OutputConditionIdentifier`.
|
||||
id: String,
|
||||
/// `/OutputCondition`, `/RegistryName` and `/Info` (left out when empty).
|
||||
condition: String,
|
||||
registry: String,
|
||||
info: String,
|
||||
/// The profile to embed (`/DestOutputProfile`): its name and number of components.
|
||||
profile: Option<(String, u8)>,
|
||||
}
|
||||
|
||||
/// The output intent and Trapped entries of the catalog and document information.
|
||||
struct Catalog {
|
||||
intent: Option<OutputIntent>,
|
||||
/// `/Trapped`, when it is written.
|
||||
trapped: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for ColorOut {
|
||||
/// Colours as they are, device colours, nothing in the catalog.
|
||||
fn default() -> Self {
|
||||
let source = cms::active();
|
||||
let cmyk_profile = source.settings().cmyk.clone();
|
||||
let intent = source.settings().intent;
|
||||
Self { source, dest: None, tagged: false, cmyk_profile, srgb_note: None, pixels: OnceLock::new(), intent, catalog: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// The profile of `set`'s destination and its colour model; `None` without conversion. A blank
|
||||
/// destination is the profile of a document in colour mode `mode`.
|
||||
fn destination(set: &PdfSettings, mode: ColorMode, source: &Cms) -> Result<Option<(String, Model)>, PdfError> {
|
||||
let o = &set.output;
|
||||
if o.conversion == ColorConversion::None {
|
||||
return Ok(None);
|
||||
}
|
||||
let name = o.destination.trim();
|
||||
let name = match (name.is_empty(), mode) {
|
||||
(false, _) => name,
|
||||
(true, ColorMode::Cmyk) => source.settings().cmyk.as_str(),
|
||||
(true, ColorMode::Rgb) => source.settings().rgb.as_str(),
|
||||
};
|
||||
let unknown = || PdfError::BadSetting(format!("output.destination: no RGB or CMYK profile is called “{name}”"));
|
||||
let info = cms::profile(name).ok_or_else(unknown)?;
|
||||
let model = match info.kind {
|
||||
ProfileKind::Rgb => Model::Rgb,
|
||||
ProfileKind::Cmyk => Model::Cmyk,
|
||||
ProfileKind::Gray => return Err(unknown()),
|
||||
};
|
||||
Ok(Some((info.name, model)))
|
||||
}
|
||||
|
||||
/// Refuse a destination profile that isn't there (when converting).
|
||||
pub(crate) fn check(set: &PdfSettings) -> Result<(), PdfError> {
|
||||
// A blank destination is the document's own profile, which is always there.
|
||||
if !set.output.destination.trim().is_empty() {
|
||||
destination(set, ColorMode::Rgb, &cms::active())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What `set`'s Output section leaves out, one warning each.
|
||||
pub(crate) fn warnings(set: &PdfSettings) -> Vec<String> {
|
||||
let o = &set.output;
|
||||
let asks = !o.output_intent.trim().is_empty() || !o.output_condition_id.trim().is_empty();
|
||||
let mut out = vec![];
|
||||
if set.standard == Standard::PdfA2b {
|
||||
if asks || o.trapped {
|
||||
out.push("PDF/A files carry their own output intent: the output intent and Trapped entries are left out".into());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let name = o.output_intent.trim();
|
||||
if !name.is_empty() && cms::profile(name).is_none() {
|
||||
out.push(format!("no profile is called “{name}”: the output intent names it without embedding it"));
|
||||
}
|
||||
if !asks && (!o.output_condition.trim().is_empty() || !o.registry.trim().is_empty()) {
|
||||
out.push("an output intent needs a profile or a condition identifier: the output condition and registry are left out".into());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl ColorOut {
|
||||
/// How `set` writes the colours of `doc` (the colour settings are the source).
|
||||
pub(crate) fn new(doc: &Document, set: &PdfSettings) -> Result<Self, PdfError> {
|
||||
let mut out = Self::default();
|
||||
let source = out.source.clone();
|
||||
let st = source.settings();
|
||||
if let Some((name, model)) = destination(set, doc.color_mode, &source)? {
|
||||
let mut settings = st.clone();
|
||||
let own = match model {
|
||||
Model::Cmyk => std::mem::replace(&mut settings.cmyk, name.clone()),
|
||||
_ => std::mem::replace(&mut settings.rgb, name.clone()),
|
||||
};
|
||||
let cms = Cms::new(&settings).map_err(|e| PdfError::BadSetting(format!("output.destination: {e}")))?;
|
||||
let keep = set.output.conversion == ColorConversion::PreserveNumbers || cms::canonical_name(&own) == name;
|
||||
if model == Model::Cmyk {
|
||||
out.cmyk_profile = name.clone();
|
||||
}
|
||||
out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep });
|
||||
}
|
||||
out.tagged = set.standard == Standard::PdfA2b
|
||||
|| match set.output.profiles {
|
||||
ProfileInclusion::None => false,
|
||||
ProfileInclusion::All | ProfileInclusion::Destination => true,
|
||||
ProfileInclusion::TaggedSource => !doc.color_profiles.is_empty(),
|
||||
};
|
||||
let rgb = match &out.dest {
|
||||
Some(d) if d.model == Model::Rgb => (d.srgb, d.cms.settings().rgb.clone()),
|
||||
_ => (source.rgb_is_srgb(), st.rgb.clone()),
|
||||
};
|
||||
if out.tagged && !rgb.0 {
|
||||
out.srgb_note = Some(format!("RGB colours are tagged with the sRGB profile: those of {} are written as their sRGB equivalents", rgb.1));
|
||||
}
|
||||
out.catalog = catalog(set);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Transparency blends in CMYK: converting to CMYK, or (without conversion) in a CMYK
|
||||
/// document (`cmyk`).
|
||||
pub(crate) fn blends_cmyk(&self, cmyk: bool) -> bool {
|
||||
self.dest.as_ref().map_or(cmyk, |d| d.model == Model::Cmyk)
|
||||
}
|
||||
|
||||
/// The ICC profile that tags CMYK colours (when they are tagged).
|
||||
pub(crate) fn cmyk_icc(&self) -> Option<krilla::icc::ICCProfile<4>> {
|
||||
krilla::icc::ICCProfile::new(&cms::icc_bytes(&self.cmyk_profile).ok()?)
|
||||
}
|
||||
|
||||
/// The name of that profile.
|
||||
pub(crate) fn cmyk_profile(&self) -> &str {
|
||||
&self.cmyk_profile
|
||||
}
|
||||
|
||||
/// Colour `c` as it is written with `intent`: converted to the destination (with `convert`;
|
||||
/// printer's marks aren't), else RGB and Lab colours of a CMYK document (`cmyk_doc`)
|
||||
/// separated into CMYK; tagged RGB colours as their sRGB equivalents.
|
||||
pub(crate) fn color(&self, c: &Color, intent: Intent, cmyk_doc: bool, convert: bool) -> Color {
|
||||
let s = &self.source;
|
||||
let c = match &self.dest {
|
||||
Some(d) if convert => d.convert(s, c, intent),
|
||||
_ if cmyk_doc && matches!(c, Color::Rgb { .. } | Color::Lab { .. }) => {
|
||||
let [c, m, y, k] = s.to_cmyk(c, intent);
|
||||
Color::Cmyk { c, m, y, k }
|
||||
}
|
||||
_ => *c,
|
||||
};
|
||||
match c {
|
||||
Color::Rgb { r, g, b } if self.srgb_note.is_some() => {
|
||||
let space = self.dest.as_ref().filter(|d| convert && d.model == Model::Rgb).map_or(&**s, |d| &d.cms);
|
||||
let [r, g, b] = space.rgb_to_srgb([r, g, b]);
|
||||
Color::Rgb { r, g, b }
|
||||
}
|
||||
c => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ink values of `c` in the CMYK space of the output (a spot colour's alternate).
|
||||
pub(crate) fn cmyk(&self, c: &Color, intent: Intent) -> [f32; 4] {
|
||||
match &self.dest {
|
||||
Some(d) if d.model == Model::Cmyk => d.cms.to_cmyk(&d.convert(&self.source, c, intent), intent),
|
||||
_ => self.source.to_cmyk(c, intent),
|
||||
}
|
||||
}
|
||||
|
||||
/// How colour images' pixels are converted; `None` when they are written as they are.
|
||||
pub(crate) fn pixels(&self) -> Option<&Pixels> {
|
||||
self.pixels
|
||||
.get_or_init(|| {
|
||||
let d = self.dest.as_ref()?;
|
||||
let intent = self.intent;
|
||||
match d.model {
|
||||
Model::Cmyk => {
|
||||
let plane = |k: bool| {
|
||||
ProofLut::build(|rgb| {
|
||||
let v = d.cms.srgb_to_cmyk(rgb, intent);
|
||||
if k { [v[3], 0.0, 0.0] } else { [v[0], v[1], v[2]] }
|
||||
})
|
||||
};
|
||||
Some(Pixels::Cmyk(Box::new([plane(false), plane(true)])))
|
||||
}
|
||||
// Images are sRGB: converted unless the destination is sRGB, they keep their
|
||||
// numbers or RGB is tagged as sRGB.
|
||||
_ if d.srgb || d.keep || self.tagged => None,
|
||||
_ => Some(Pixels::Rgb(Box::new(ProofLut::build(|rgb| d.cms.srgb_to_rgb(rgb))))),
|
||||
}
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// `pdf` with the output intent and Trapped entries → (the file, warnings).
|
||||
pub(crate) fn write_catalog(&self, pdf: Vec<u8>) -> Result<(Vec<u8>, Vec<String>), PdfError> {
|
||||
let Some(cat) = &self.catalog else { return Ok((pdf, vec![])) };
|
||||
let xref = Xref::read(&pdf).ok_or_else(|| PdfError::Write("the written PDF has no cross-reference table".into()))?;
|
||||
let mut patch = Patch::new(&xref);
|
||||
let mut warnings = vec![];
|
||||
if let Some(OutputIntent { id, condition, registry, info, profile }) = &cat.intent {
|
||||
let root = xref.trailer_ref(&pdf, b"/Root").and_then(|n| xref.dict(&pdf, n));
|
||||
let Some((root, root_end)) = root else { return Err(PdfError::Write("the written PDF has no catalog".into())) };
|
||||
if crate::lab_spot::find(pdf.get(root..root_end).unwrap_or_default(), b"/OutputIntents", 0).is_some() {
|
||||
warnings.push("the file has an output intent already: the one asked for is left out".into());
|
||||
} else {
|
||||
let mut dict = format!("<</Type/OutputIntent/S/GTS_PDFX/OutputConditionIdentifier{}", text_string(id));
|
||||
for (key, v) in [("OutputCondition", condition), ("RegistryName", registry), ("Info", info)] {
|
||||
if !v.is_empty() {
|
||||
dict.push_str(&format!("/{key}{}", text_string(v)));
|
||||
}
|
||||
}
|
||||
if let Some((name, n)) = profile {
|
||||
let icc = cms::icc_bytes(name).map_err(|e| PdfError::Write(format!("output intent profile: {e}")))?;
|
||||
let data = deflate(&icc).map_err(|e| PdfError::Write(format!("output intent profile: {e}")))?;
|
||||
let mut stream = format!("<</N {n}/Length {}/Filter/FlateDecode>>\nstream\n", data.len()).into_bytes();
|
||||
stream.extend_from_slice(&data);
|
||||
stream.extend_from_slice(b"\nendstream");
|
||||
dict.push_str(&format!("/DestOutputProfile {} 0 R", patch.add_object(stream)));
|
||||
}
|
||||
dict.push_str(">>");
|
||||
let oi = patch.add_object(dict.into_bytes());
|
||||
patch.replace(root, root, format!("/OutputIntents[{oi} 0 R]").into_bytes());
|
||||
}
|
||||
}
|
||||
if let Some(trapped) = cat.trapped {
|
||||
let entry = if trapped { "/Trapped/True" } else { "/Trapped/False" };
|
||||
match xref.trailer_ref(&pdf, b"/Info").and_then(|n| xref.dict(&pdf, n)) {
|
||||
Some((info, end)) if crate::lab_spot::find(pdf.get(info..end).unwrap_or_default(), b"/Trapped", 0).is_none() => {
|
||||
patch.replace(info, info, entry.as_bytes().to_vec());
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
let n = patch.add_object(format!("<<{entry}>>").into_bytes());
|
||||
patch.trailer_entry(format!("/Info {n} 0 R").as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((patch.apply(&pdf, &xref)?, warnings))
|
||||
}
|
||||
}
|
||||
|
||||
impl Dest {
|
||||
/// `c` converted from `source` (see the module documentation).
|
||||
fn convert(&self, source: &Cms, c: &Color, intent: Intent) -> Color {
|
||||
match (self.model, c) {
|
||||
(_, Color::Gray { .. }) | (Model::Cmyk, Color::Cmyk { .. }) | (Model::Rgb, Color::Rgb { .. })
|
||||
if self.keep || matches!(c, Color::Gray { .. }) =>
|
||||
{
|
||||
*c
|
||||
}
|
||||
(Model::Cmyk, _) => {
|
||||
// Through Lab (CMYK of another profile, Lab) or the display colour (RGB).
|
||||
let [c, m, y, k] = match c {
|
||||
Color::Rgb { .. } => self.cms.srgb_to_cmyk(source.display_rgb(c), intent),
|
||||
_ => self.cms.lab_to_cmyk(source.lab(c), intent),
|
||||
};
|
||||
Color::Cmyk { c, m, y, k }
|
||||
}
|
||||
_ => {
|
||||
let [r, g, b] = self.cms.srgb_to_rgb(source.display_rgb(c));
|
||||
Color::Rgb { r, g, b }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog entries `set` asks for (none in PDF/A files, see [`warnings`]).
|
||||
fn catalog(set: &PdfSettings) -> Option<Catalog> {
|
||||
let o = &set.output;
|
||||
if set.standard == Standard::PdfA2b {
|
||||
return None;
|
||||
}
|
||||
let (name, id) = (o.output_intent.trim(), o.output_condition_id.trim());
|
||||
let intent = (!name.is_empty() || !id.is_empty()).then(|| {
|
||||
let profile = cms::profile(name).and_then(|p| match p.kind {
|
||||
ProfileKind::Rgb => Some((p.name, 3)),
|
||||
ProfileKind::Cmyk => Some((p.name, 4)),
|
||||
ProfileKind::Gray => None,
|
||||
});
|
||||
let text = |s: &str| s.trim().chars().take(MAX_TEXT).collect::<String>();
|
||||
OutputIntent {
|
||||
id: text(if id.is_empty() { name } else { id }),
|
||||
condition: text(&o.output_condition),
|
||||
registry: text(&o.registry),
|
||||
info: text(name),
|
||||
profile,
|
||||
}
|
||||
});
|
||||
let trapped = (o.trapped || intent.is_some()).then_some(o.trapped);
|
||||
(intent.is_some() || trapped.is_some()).then_some(Catalog { intent, trapped })
|
||||
}
|
||||
|
||||
/// `s` as a PDF text string: a literal string when it is printable ASCII, else UTF-16BE (with its
|
||||
/// byte order mark) in hex.
|
||||
fn text_string(s: &str) -> String {
|
||||
if s.bytes().all(|b| (b' '..=b'~').contains(&b)) {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('(');
|
||||
for c in s.chars() {
|
||||
if matches!(c, '(' | ')' | '\\') {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.push(')');
|
||||
out
|
||||
} else {
|
||||
let mut out = String::from("<FEFF");
|
||||
for u in s.encode_utf16() {
|
||||
out.push_str(&format!("{u:04X}"));
|
||||
}
|
||||
out.push('>');
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn deflate(data: &[u8]) -> std::io::Result<Vec<u8>> {
|
||||
use std::io::Write;
|
||||
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
e.write_all(data)?;
|
||||
e.finish()
|
||||
}
|
||||
|
||||
/// How colour images' pixels are converted to the destination.
|
||||
pub(crate) enum Pixels {
|
||||
/// To another RGB space.
|
||||
Rgb(Box<ProofLut>),
|
||||
/// To CMYK: the cyan, magenta and yellow plane, and the black one.
|
||||
Cmyk(Box<[ProofLut; 2]>),
|
||||
}
|
||||
|
||||
impl Pixels {
|
||||
pub(crate) fn is_cmyk(&self) -> bool {
|
||||
matches!(self, Self::Cmyk(_))
|
||||
}
|
||||
|
||||
/// Convert the RGB samples of straight RGBA pixels `rgba` in place (RGB destinations).
|
||||
pub(crate) fn rgb_in_place(&self, rgba: &mut [u8]) {
|
||||
if let Self::Rgb(lut) = self {
|
||||
for p in rgba.as_chunks_mut::<4>().0 {
|
||||
let [r, g, b] = lut.apply8([p[0], p[1], p[2]]);
|
||||
(p[0], p[1], p[2]) = (r, g, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The CMYK samples of straight RGBA pixels `rgba` (CMYK destinations).
|
||||
pub(crate) fn cmyk(&self, rgba: &[u8]) -> Option<Vec<u8>> {
|
||||
let Self::Cmyk(luts) = self else { return None };
|
||||
let [cmy, k] = &**luts;
|
||||
Some(
|
||||
rgba.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let rgb = [p[0], p[1], p[2]];
|
||||
let [c, m, y] = cmy.apply8(rgb);
|
||||
[c, m, y, k.apply8(rgb)[0]]
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Edits to a PDF the export wrote (uncompressed objects, one cross-reference table starting at
|
||||
//! object 0): bytes replaced or inserted inside objects, new objects and trailer entries. The
|
||||
//! cross-reference table is written again to match.
|
||||
|
||||
use crate::PdfError;
|
||||
use crate::lab_spot::{find, rfind, xref_offset};
|
||||
|
||||
/// The cross-reference table of a written PDF and its trailer.
|
||||
pub(crate) struct Xref {
|
||||
/// Where the table (`xref`) starts.
|
||||
at: usize,
|
||||
/// Where its first 20-byte entry starts.
|
||||
entries: usize,
|
||||
/// Number of entries (the trailer's `/Size`).
|
||||
count: usize,
|
||||
/// The trailer dictionary, `<<` to `>>` included.
|
||||
trailer: (usize, usize),
|
||||
}
|
||||
|
||||
/// Most bytes of a number in PDF syntax read here.
|
||||
const MAX_DIGITS: usize = 10;
|
||||
|
||||
/// The unsigned number `pdf` holds at `at` (after white space), and where it ends.
|
||||
fn number(pdf: &[u8], mut at: usize) -> Option<(usize, usize)> {
|
||||
while pdf.get(at).is_some_and(u8::is_ascii_whitespace) {
|
||||
at += 1;
|
||||
}
|
||||
let digits = pdf.get(at..)?.iter().take(MAX_DIGITS + 1).take_while(|b| b.is_ascii_digit()).count();
|
||||
if digits == 0 || digits > MAX_DIGITS {
|
||||
return None;
|
||||
}
|
||||
Some((std::str::from_utf8(pdf.get(at..at + digits)?).ok()?.parse().ok()?, at + digits))
|
||||
}
|
||||
|
||||
impl Xref {
|
||||
/// The table `startxref` points at; `None` for files written another way.
|
||||
pub fn read(pdf: &[u8]) -> Option<Self> {
|
||||
let at = xref_offset(pdf)?;
|
||||
let (first, after) = number(pdf, at + 4)?;
|
||||
let (count, after) = number(pdf, after)?;
|
||||
let entries = find(pdf, b"\n", after)? + 1;
|
||||
let table_end = entries.checked_add(count.checked_mul(20)?)?;
|
||||
if first != 0 || count == 0 || table_end > pdf.len() {
|
||||
return None;
|
||||
}
|
||||
let start = find(pdf, b"<<", find(pdf, b"trailer", table_end)?)?;
|
||||
let end = rfind(pdf.get(start..rfind(pdf, b"startxref")?)?, b">>")? + start + 2;
|
||||
Some(Self { at, entries, count, trailer: (start, end) })
|
||||
}
|
||||
|
||||
/// Where in-use object `n` starts.
|
||||
fn offset(&self, pdf: &[u8], n: u32) -> Option<usize> {
|
||||
let e = self.entries + 20 * usize::try_from(n).ok().filter(|n| *n < self.count)?;
|
||||
let entry = pdf.get(e..e + 20)?;
|
||||
(entry.get(17) == Some(&b'n')).then_some(())?;
|
||||
number(entry, 0).map(|(off, _)| off)
|
||||
}
|
||||
|
||||
/// The object the trailer's `key` (`b"/Root"`) refers to.
|
||||
pub fn trailer_ref(&self, pdf: &[u8], key: &[u8]) -> Option<u32> {
|
||||
let dict = pdf.get(self.trailer.0..self.trailer.1)?;
|
||||
let at = find(dict, key, 0)? + key.len();
|
||||
// A key that only starts with `key` (`/Info` in `/InfoX`) isn't it.
|
||||
if dict.get(at).is_some_and(|b| b.is_ascii_alphanumeric()) {
|
||||
return None;
|
||||
}
|
||||
u32::try_from(number(dict, at)?.0).ok()
|
||||
}
|
||||
|
||||
/// The contents of the dictionary object `n` is (just after its `<<`, up to its `endobj`).
|
||||
pub fn dict(&self, pdf: &[u8], n: u32) -> Option<(usize, usize)> {
|
||||
let off = self.offset(pdf, n)?;
|
||||
let (num, after) = number(pdf, off)?;
|
||||
let head = pdf.get(after..after + 6)?;
|
||||
if num != n as usize || head != b" 0 obj" {
|
||||
return None;
|
||||
}
|
||||
let mut at = after + 6;
|
||||
while pdf.get(at).is_some_and(u8::is_ascii_whitespace) {
|
||||
at += 1;
|
||||
}
|
||||
(pdf.get(at..at + 2)? == b"<<").then_some(())?;
|
||||
Some((at + 2, find(pdf, b"endobj", at)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// A replacement of bytes `start..end` of the file.
|
||||
type Edit = (usize, usize, Vec<u8>);
|
||||
|
||||
/// The edits of one PDF, applied together by [`Self::apply`].
|
||||
pub(crate) struct Patch {
|
||||
edits: Vec<Edit>,
|
||||
/// New objects' bodies, numbered from the table's size on.
|
||||
objects: Vec<Vec<u8>>,
|
||||
/// Entries added to the trailer dictionary.
|
||||
trailer: Vec<u8>,
|
||||
size: usize,
|
||||
}
|
||||
|
||||
impl Patch {
|
||||
pub fn new(xref: &Xref) -> Self {
|
||||
Self { edits: vec![], objects: vec![], trailer: vec![], size: xref.count }
|
||||
}
|
||||
|
||||
/// Replace bytes `start..end` (inside the objects) with `with`; `start == end` inserts.
|
||||
pub fn replace(&mut self, start: usize, end: usize, with: Vec<u8>) {
|
||||
self.edits.push((start, end, with));
|
||||
}
|
||||
|
||||
/// Add an object of `body` (a dictionary, array or stream with its dictionary) → its number.
|
||||
pub fn add_object(&mut self, body: Vec<u8>) -> u32 {
|
||||
self.objects.push(body);
|
||||
u32::try_from(self.size + self.objects.len() - 1).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
/// Add `entry` (`/Info 12 0 R`) to the trailer dictionary.
|
||||
pub fn trailer_entry(&mut self, entry: &[u8]) {
|
||||
self.trailer.extend_from_slice(entry);
|
||||
}
|
||||
|
||||
/// `pdf` with the edits, new objects and trailer entries, and a cross-reference table that
|
||||
/// matches. Refused when an edit overlaps another or reaches past the objects.
|
||||
pub fn apply(mut self, pdf: &[u8], xref: &Xref) -> Result<Vec<u8>, PdfError> {
|
||||
let bad = |why: &str| PdfError::Write(format!("can't edit the written PDF: {why}"));
|
||||
self.edits.sort_by_key(|e| (e.0, e.1));
|
||||
if self.edits.windows(2).any(|w| w[0].1 > w[1].0) || self.edits.iter().any(|e| e.0 > e.1 || e.1 > xref.at) {
|
||||
return Err(bad("edits overlap"));
|
||||
}
|
||||
// Where a byte offset of the old file lands in the new one.
|
||||
let edits = &self.edits;
|
||||
let moved = |off: usize| -> usize {
|
||||
edits.iter().filter(|e| e.1 <= off).fold(off as isize, |o, e| o + e.2.len() as isize - (e.1 - e.0) as isize) as usize
|
||||
};
|
||||
let added: usize = self.edits.iter().map(|e| e.2.len()).sum::<usize>() + self.objects.iter().map(|o| o.len() + 32).sum::<usize>();
|
||||
let mut out = Vec::with_capacity(pdf.len() + added + 64);
|
||||
let mut at = 0;
|
||||
for (start, end, new) in edits.iter() {
|
||||
out.extend_from_slice(pdf.get(at..*start).ok_or_else(|| bad("bad edit"))?);
|
||||
out.extend_from_slice(new);
|
||||
at = *end;
|
||||
}
|
||||
out.extend_from_slice(pdf.get(at..xref.at).ok_or_else(|| bad("bad edit"))?);
|
||||
let mut offsets = Vec::with_capacity(self.objects.len());
|
||||
for (i, body) in self.objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", self.size + i).as_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
// The table: the old entries moved (free ones as they are), then the new objects'.
|
||||
let table = out.len();
|
||||
let size = self.size + self.objects.len();
|
||||
out.extend_from_slice(format!("xref\n0 {size}\n").as_bytes());
|
||||
let old = pdf.get(xref.entries..xref.entries + 20 * xref.count).ok_or_else(|| bad("bad cross-reference table"))?;
|
||||
for e in old.chunks(20) {
|
||||
match number(e, 0) {
|
||||
Some((off, _)) if e.get(17) == Some(&b'n') => {
|
||||
out.extend_from_slice(format!("{:010}", moved(off)).as_bytes());
|
||||
out.extend_from_slice(e.get(10..).unwrap_or_default());
|
||||
}
|
||||
_ => out.extend_from_slice(e),
|
||||
}
|
||||
}
|
||||
let eol = old.get(18..20).unwrap_or(b"\r\n");
|
||||
for off in offsets {
|
||||
out.extend_from_slice(format!("{off:010} 00000 n").as_bytes());
|
||||
out.extend_from_slice(eol);
|
||||
}
|
||||
// The trailer, with its size and the new entries.
|
||||
let (ts, te) = xref.trailer;
|
||||
let dict = pdf.get(ts..te.saturating_sub(2)).ok_or_else(|| bad("bad trailer"))?;
|
||||
let size_at = find(dict, b"/Size", 0).ok_or_else(|| bad("the trailer has no size"))? + 5;
|
||||
let (_, size_end) = number(dict, size_at).ok_or_else(|| bad("the trailer has no size"))?;
|
||||
let (Some(head), Some(tail)) = (dict.get(..size_at), dict.get(size_end..)) else { return Err(bad("bad trailer")) };
|
||||
out.extend_from_slice(b"trailer\n");
|
||||
out.extend_from_slice(head);
|
||||
out.extend_from_slice(format!(" {size}").as_bytes());
|
||||
out.extend_from_slice(tail);
|
||||
out.extend_from_slice(&self.trailer);
|
||||
out.extend_from_slice(format!(">>\nstartxref\n{table}\n%%EOF").as_bytes());
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -397,6 +398,7 @@ impl PdfSettings {
|
||||
if !self.standard.supported() {
|
||||
return Err(PdfError::Unsupported(format!("{} output is not supported yet", self.standard.label())));
|
||||
}
|
||||
crate::output::check(self)?;
|
||||
self.check_values()
|
||||
}
|
||||
|
||||
@@ -432,23 +434,15 @@ impl PdfSettings {
|
||||
/// Options that are accepted but not applied by the writer yet, one warning each.
|
||||
pub fn warnings(&self) -> Vec<String> {
|
||||
let d = Self::default();
|
||||
let o = &self.output;
|
||||
let s = &self.security;
|
||||
[
|
||||
(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"),
|
||||
(o.conversion != ColorConversion::None, "colour conversion is not applied yet: colours are written as they are"),
|
||||
(o.profiles != ProfileInclusion::None, "ICC profiles are not embedded yet"),
|
||||
(
|
||||
!o.output_intent.is_empty()
|
||||
|| !o.output_condition.is_empty()
|
||||
|| !o.output_condition_id.is_empty()
|
||||
|| !o.registry.is_empty()
|
||||
|| o.trapped,
|
||||
"output intent and trapped entries are 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",
|
||||
),
|
||||
(!self.advanced.outline_text, "text is exported as outlines: real, selectable text is not written yet"),
|
||||
(
|
||||
!s.protected()
|
||||
&& (s.printing != d.security.printing
|
||||
@@ -463,6 +457,7 @@ impl PdfSettings {
|
||||
.filter(|(on, _)| *on)
|
||||
.map(|(_, w)| w.to_string())
|
||||
.chain(crate::encrypt::warnings(self))
|
||||
.chain(crate::output::warnings(self))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
//! Save PDF › Output: colours converted to the destination (or their numbers preserved), ICC
|
||||
//! profiles tagging them, images converted too, and the output intent and Trapped entries.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use hayro_syntax::Pdf;
|
||||
use hayro_syntax::object::{Array, Dict, Name, ObjectIdentifier, Stream};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::cms::{self, Cms, ColorSettings, DEVICE_CMYK, GENERIC_CMYK, SRGB};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, ColorMode, Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// A document of `mode` with a rectangle filled with each of `fills`, side by side.
|
||||
fn doc(mode: ColorMode, fills: &[Color]) -> Document {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
d.color_mode = mode;
|
||||
let l = d.layers[0].id;
|
||||
for (i, c) in fills.iter().enumerate() {
|
||||
let x = 10.0 + 30.0 * i as f64;
|
||||
let n =
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), Appearance::basic(Paint::solid(*c), Paint::None, 0.0));
|
||||
d.insert(Some(l), i, n).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// The export of `d` with `settings` (content streams left readable).
|
||||
fn pdf(d: &Document, settings: Value) -> ExportReport {
|
||||
let mut settings: PdfSettings = serde_json::from_value(settings).unwrap();
|
||||
settings.compression.compress_text = false;
|
||||
export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
/// The fill colours the first page's content sets: (operator, operands).
|
||||
fn colours(pdf: &[u8]) -> Vec<(String, Vec<f32>)> {
|
||||
let file = Pdf::new(Arc::new(pdf.to_vec())).unwrap();
|
||||
let pages = file.pages();
|
||||
let content = String::from_utf8_lossy(pages.iter().next().unwrap().page_stream().unwrap()).into_owned();
|
||||
let (mut nums, mut out) = (vec![], vec![]);
|
||||
for t in content.split_whitespace() {
|
||||
if let Ok(v) = t.parse::<f32>() {
|
||||
nums.push(v);
|
||||
continue;
|
||||
}
|
||||
if ["rg", "k", "g", "scn"].contains(&t) {
|
||||
out.push((t.to_string(), nums.clone()));
|
||||
}
|
||||
nums.clear();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `v` as the writer stores it (8 bits a component).
|
||||
fn q8(v: &[f32]) -> Vec<f32> {
|
||||
v.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0).collect()
|
||||
}
|
||||
|
||||
fn close(a: &[f32], b: &[f32]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3)
|
||||
}
|
||||
|
||||
/// The colour management of a CMYK destination, as the export builds it.
|
||||
fn to_cmyk(dest: &str) -> Cms {
|
||||
Cms::new(&ColorSettings { cmyk: dest.into(), ..cms::active_settings() }).unwrap()
|
||||
}
|
||||
|
||||
/// The ICC profiles the ICC-based colour spaces of `pdf` use: (components, profile bytes).
|
||||
fn icc_profiles(pdf: &[u8]) -> Vec<(i32, Vec<u8>)> {
|
||||
let file = Pdf::new(Arc::new(pdf.to_vec())).unwrap();
|
||||
let text = String::from_utf8_lossy(pdf);
|
||||
let mut out = vec![];
|
||||
for (at, _) in text.match_indices("[/ICCBased ") {
|
||||
let n: i32 = text[at + 11..].split(' ').next().unwrap().parse().unwrap();
|
||||
let s = file.xref().get::<Stream<'_>>(ObjectIdentifier::new(n, 0)).unwrap();
|
||||
let entry = (s.dict().get::<i32>(b"N").unwrap(), s.decoded().unwrap().into_owned());
|
||||
if !out.contains(&entry) {
|
||||
out.push(entry);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converting_to_a_cmyk_destination_writes_k_instead_of_rg() {
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
let d = doc(ColorMode::Rgb, &[red]);
|
||||
assert_eq!(colours(&pdf(&d, json!({})).bytes), [("rg".to_string(), vec![1.0, 0.0, 0.0])], "without conversion");
|
||||
let ink = q8(&to_cmyk(GENERIC_CMYK).srgb_to_cmyk([1.0, 0.0, 0.0], cms::active_settings().intent));
|
||||
for conversion in ["destination", "preserveNumbers"] {
|
||||
let r = pdf(&d, json!({"output": {"conversion": conversion, "destination": GENERIC_CMYK}}));
|
||||
let c = colours(&r.bytes);
|
||||
assert!(c.len() == 1 && c[0].0 == "k" && close(&c[0].1, &ink), "{conversion}: {c:?} vs {ink:?}");
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
// A blank destination is the document's profile: the RGB document's colours stay RGB.
|
||||
assert_eq!(colours(&pdf(&d, json!({"output": {"conversion": "destination"}})).bytes)[0].0, "rg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_numbers_keeps_cmyk_values_that_destination_converts() {
|
||||
let ink = Color::cmyk(0.1, 0.2, 0.3, 0.4);
|
||||
let d = doc(ColorMode::Rgb, &[ink, Color::Gray { k: 0.25 }]);
|
||||
let numbers = q8(&[0.1, 0.2, 0.3, 0.4]);
|
||||
let fills = |conversion: &str, dest: &str| colours(&pdf(&d, json!({"output": {"conversion": conversion, "destination": dest}})).bytes);
|
||||
let kept = fills("preserveNumbers", DEVICE_CMYK);
|
||||
assert!(kept[0].0 == "k" && close(&kept[0].1, &numbers), "{kept:?}");
|
||||
// Grey stays grey.
|
||||
assert!(kept[1].0 == "g" && close(&kept[1].1, &q8(&[0.75])), "{kept:?}");
|
||||
// Convert to destination: CMYK of another profile goes through Lab into the destination.
|
||||
let converted = fills("destination", DEVICE_CMYK);
|
||||
let expected = q8(&to_cmyk(DEVICE_CMYK).lab_to_cmyk(cms::active().lab(&ink), cms::active_settings().intent));
|
||||
assert!(converted[0].0 == "k" && close(&converted[0].1, &expected), "{converted:?} vs {expected:?}");
|
||||
assert!(converted[0].1.iter().zip(&numbers).any(|(a, b)| (a - b).abs() > 0.02), "the numbers change: {converted:?}");
|
||||
// …unless the destination is their own profile.
|
||||
let own = fills("destination", &cms::active_settings().cmyk);
|
||||
assert!(close(&own[0].1, &numbers), "{own:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converting_to_rgb_turns_cmyk_into_rg() {
|
||||
let ink = Color::cmyk(0.0, 1.0, 1.0, 0.0);
|
||||
let d = doc(ColorMode::Cmyk, &[ink]);
|
||||
assert_eq!(colours(&pdf(&d, json!({})).bytes)[0].0, "k");
|
||||
let c = colours(&pdf(&d, json!({"output": {"conversion": "preserveNumbers", "destination": SRGB}})).bytes);
|
||||
assert!(c[0].0 == "rg" && close(&c[0].1, &q8(&cms::active().display_rgb(&ink))), "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_destination_is_refused() {
|
||||
let d = doc(ColorMode::Rgb, &[Color::BLACK]);
|
||||
let set: PdfSettings = serde_json::from_value(json!({"output": {"conversion": "destination", "destination": "No Such Press"}})).unwrap();
|
||||
assert!(matches!(set.check(), Err(PdfError::BadSetting(m)) if m.contains("No Such Press")));
|
||||
assert!(export(&d, &PdfOptions { settings: set, ..Default::default() }).is_err());
|
||||
// Not converting, the destination doesn't matter.
|
||||
let set: PdfSettings = serde_json::from_value(json!({"output": {"destination": "No Such Press"}})).unwrap();
|
||||
assert_eq!(set.check(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_tag_colours_with_icc_based_spaces() {
|
||||
let d = doc(ColorMode::Rgb, &[Color::rgb(0.2, 0.4, 0.6), Color::cmyk(0.1, 0.2, 0.3, 0.4), Color::Gray { k: 0.5 }]);
|
||||
let none = pdf(&d, json!({}));
|
||||
assert!(icc_profiles(&none.bytes).is_empty());
|
||||
assert!(colours(&none.bytes).iter().all(|c| c.0 != "scn"));
|
||||
let all = pdf(&d, json!({"output": {"profiles": "all"}}));
|
||||
let profiles = icc_profiles(&all.bytes);
|
||||
let mut n: Vec<i32> = profiles.iter().map(|p| p.0).collect();
|
||||
n.sort();
|
||||
assert_eq!(n, [1, 3, 4], "grey, RGB and CMYK spaces");
|
||||
assert!(profiles.iter().any(|p| p.0 == 4 && p.1 == *cms::icc_bytes(&cms::active_settings().cmyk).unwrap()), "the working CMYK profile");
|
||||
assert!(colours(&all.bytes).iter().all(|c| c.0 == "scn"), "{:?}", colours(&all.bytes));
|
||||
// The destination profile tags converted colours.
|
||||
let dest = pdf(&d, json!({"output": {"conversion": "destination", "destination": DEVICE_CMYK, "profiles": "destination"}}));
|
||||
let profiles = icc_profiles(&dest.bytes);
|
||||
assert!(profiles.iter().any(|p| p.0 == 4 && p.1 == *cms::icc_bytes(DEVICE_CMYK).unwrap()), "the destination");
|
||||
// Tagged source profiles: only a document that was assigned profiles is tagged.
|
||||
assert!(icc_profiles(&pdf(&d, json!({"output": {"profiles": "taggedSource"}})).bytes).is_empty());
|
||||
let mut tagged = d.clone();
|
||||
tagged.color_profiles.cmyk = Some(GENERIC_CMYK.into());
|
||||
assert_eq!(icc_profiles(&pdf(&tagged, json!({"output": {"profiles": "taggedSource"}})).bytes).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tagged_cmyk_documents_blend_in_the_cmyk_profile_space() {
|
||||
let mut d = doc(ColorMode::Cmyk, &[Color::cmyk(1.0, 0.0, 0.0, 0.0)]);
|
||||
let mut n = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)),
|
||||
Appearance::basic(Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), Paint::None, 0.0),
|
||||
);
|
||||
n.opacity = 0.5;
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 1, n).unwrap();
|
||||
let r = pdf(&d, json!({"output": {"profiles": "all"}}));
|
||||
let text = String::from_utf8_lossy(&r.bytes);
|
||||
let group = text.split("/Group<<").nth(1).unwrap();
|
||||
let cs: i32 = group.split("/CS ").nth(1).unwrap().split(' ').next().unwrap().parse().unwrap();
|
||||
let space = text.split(&format!("\n{cs} 0 obj\n")).nth(1).unwrap();
|
||||
let stream: i32 = space.strip_prefix("[/ICCBased ").unwrap().split(' ').next().unwrap().parse().unwrap();
|
||||
let file = Pdf::new(Arc::new(r.bytes.clone())).unwrap();
|
||||
assert_eq!(file.xref().get::<Stream<'_>>(ObjectIdentifier::new(stream, 0)).unwrap().dict().get::<i32>(b"N"), Some(4), "{group}");
|
||||
}
|
||||
|
||||
/// A 4 × 4 PNG of one colour.
|
||||
fn png(rgb: [u8; 3]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
image::DynamicImage::from(image::RgbImage::from_pixel(4, 4, image::Rgb(rgb)))
|
||||
.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn image_doc(bytes: Vec<u8>) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.images.insert("img".into(), ImageBlob::new("image/png", bytes));
|
||||
let image = ImageObject { key: "img".into(), width: 4, height: 4, xf: Affine::scale(10.0), link: None, placement: Default::default() };
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Image(image));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The colour of the first image of the PDF `bytes` imports as, at its first pixel.
|
||||
fn first_pixel(bytes: &[u8]) -> [u8; 3] {
|
||||
let back = import(bytes).unwrap();
|
||||
let blob = back.images.values().next().unwrap();
|
||||
image::load_from_memory(&blob.bytes).unwrap().to_rgb8().get_pixel(0, 0).0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_are_converted_too() {
|
||||
let d = image_doc(png([200, 40, 30]));
|
||||
let plain = pdf(&d, json!({}));
|
||||
assert!(String::from_utf8_lossy(&plain.bytes).contains("/ColorSpace/DeviceRGB"));
|
||||
let r = pdf(&d, json!({"output": {"conversion": "preserveNumbers", "destination": GENERIC_CMYK}}));
|
||||
assert!(String::from_utf8_lossy(&r.bytes).contains("/ColorSpace/DeviceCMYK"), "a CMYK image");
|
||||
// CMYK images aren't written as JPEG.
|
||||
let jpeg =
|
||||
pdf(&d, json!({"compression": {"color": {"compression": "jpeg"}}, "output": {"conversion": "destination", "destination": GENERIC_CMYK}}));
|
||||
assert!(!String::from_utf8_lossy(&jpeg.bytes).contains("/DCTDecode"));
|
||||
assert!(jpeg.warnings.iter().any(|w| w.contains("ZIP")), "{:?}", jpeg.warnings);
|
||||
// An RGB destination other than sRGB changes the pixels (unless their numbers are kept).
|
||||
assert_eq!(first_pixel(&plain.bytes), [200, 40, 30]);
|
||||
let p3 = |conversion: &str| first_pixel(&pdf(&d, json!({"output": {"conversion": conversion, "destination": cms::DISPLAY_P3}})).bytes);
|
||||
assert_ne!(p3("destination"), [200, 40, 30]);
|
||||
assert_eq!(p3("preserveNumbers"), [200, 40, 30]);
|
||||
// Grey images stay grey.
|
||||
let grey = image_doc(png([90, 90, 90]));
|
||||
let r = pdf(&grey, json!({"output": {"conversion": "destination", "destination": GENERIC_CMYK}}));
|
||||
assert!(String::from_utf8_lossy(&r.bytes).contains("/ColorSpace/DeviceGray"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printers_marks_keep_their_inks() {
|
||||
let d = doc(ColorMode::Cmyk, &[Color::cmyk(0.0, 0.0, 1.0, 0.0)]);
|
||||
let r = pdf(&d, json!({"marks": {"colorBars": true}, "output": {"conversion": "destination", "destination": SRGB}}));
|
||||
let ops: Vec<String> = colours(&r.bytes).into_iter().map(|c| c.0).collect();
|
||||
assert!(ops.contains(&"rg".to_string()) && ops.contains(&"k".to_string()), "art converted, colour bars kept: {ops:?}");
|
||||
}
|
||||
|
||||
/// The catalog's output intent of `bytes` (opened with `password`).
|
||||
fn output_intent(bytes: &[u8], password: Option<&str>, f: impl FnOnce(&Dict<'_>)) {
|
||||
let file = crate::pages::open(bytes, password).unwrap();
|
||||
let xref = file.xref();
|
||||
let catalog = xref.get::<Dict<'_>>(xref.root_id()).unwrap();
|
||||
let intents = catalog.get::<Array<'_>>(b"OutputIntents").unwrap();
|
||||
f(&intents.iter::<Dict<'_>>().next().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_output_intent_embeds_its_profile_and_trapped_is_written() {
|
||||
let mut d = doc(ColorMode::Cmyk, &[Color::cmyk(0.0, 0.0, 1.0, 0.0)]);
|
||||
let out = json!({"outputIntent": GENERIC_CMYK, "outputCondition": "Coated (press)", "outputConditionId": "Généric", "registry": "http://example.com", "trapped": true});
|
||||
for (title, password) in [("Poster", None), ("", None), ("Poster", Some("secret"))] {
|
||||
d.title = title.into();
|
||||
let mut set = json!({"output": out});
|
||||
if let Some(p) = password {
|
||||
set["security"] = json!({"openPassword": p});
|
||||
}
|
||||
let r = pdf(&d, set);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
output_intent(&r.bytes, password, |oi| {
|
||||
assert_eq!(oi.get::<Name<'_>>(b"S").unwrap().as_ref(), b"GTS_PDFX");
|
||||
let text = |k: &[u8]| oi.get::<hayro_syntax::object::String<'_>>(k).map(|s| s.as_bytes().to_vec());
|
||||
assert_eq!(text(b"OutputCondition").unwrap(), b"Coated (press)");
|
||||
assert_eq!(text(b"RegistryName").unwrap(), b"http://example.com");
|
||||
// Non-ASCII text is UTF-16 with its byte order mark.
|
||||
let id: Vec<u8> = [0xFE, 0xFF].into_iter().chain("Généric".encode_utf16().flat_map(u16::to_be_bytes)).collect();
|
||||
assert_eq!(text(b"OutputConditionIdentifier").unwrap(), id);
|
||||
let profile = oi.get::<Stream<'_>>(b"DestOutputProfile").unwrap();
|
||||
assert_eq!(profile.dict().get::<i32>(b"N"), Some(4));
|
||||
assert_eq!(profile.decoded().unwrap().as_ref(), &*cms::icc_bytes(GENERIC_CMYK).unwrap());
|
||||
});
|
||||
if password.is_none() {
|
||||
assert!(String::from_utf8_lossy(&r.bytes).contains("/Trapped/True"), "title {title:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
import_with_report(&r.bytes, &ImportOptions { password: password.map(str::to_string), ..Default::default() })
|
||||
.unwrap()
|
||||
.document
|
||||
.artboards
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
// A condition identifier alone (a registered printing condition) names no profile; untrapped
|
||||
// output says so.
|
||||
let r = pdf(&d, json!({"output": {"outputConditionId": "CGATS TR 001"}}));
|
||||
output_intent(&r.bytes, None, |oi| assert!(oi.get::<Stream<'_>>(b"DestOutputProfile").is_none()));
|
||||
assert!(String::from_utf8_lossy(&r.bytes).contains("/Trapped/False"));
|
||||
// Nothing asked, nothing written.
|
||||
let plain = String::from_utf8_lossy(&pdf(&d, json!({})).bytes).into_owned();
|
||||
assert!(!plain.contains("/OutputIntents") && !plain.contains("/Trapped"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_a_keeps_its_own_output_intent() {
|
||||
let d = doc(ColorMode::Rgb, &[Color::BLACK]);
|
||||
let r = pdf(&d, json!({"standard": "pdfA2b", "output": {"outputIntent": GENERIC_CMYK, "trapped": true}}));
|
||||
let text = String::from_utf8_lossy(&r.bytes);
|
||||
assert!(text.contains("GTS_PDFA1") && !text.contains("GTS_PDFX") && !text.contains("/Trapped"));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("PDF/A")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_a_tags_cmyk_colours_with_the_cmyk_profile() {
|
||||
let d = doc(ColorMode::Cmyk, &[Color::cmyk(0.1, 0.2, 0.3, 0.4)]);
|
||||
let r = pdf(&d, json!({"standard": "pdfA2b"}));
|
||||
assert!(icc_profiles(&r.bytes).iter().any(|p| p.0 == 4));
|
||||
}
|
||||
@@ -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,18 +76,25 @@ fn options_not_applied_yet_come_back_as_warnings() {
|
||||
(json!({"thumbnails": true}), "thumbnails"),
|
||||
(json!({"fastWebView": true}), "fast web view"),
|
||||
(json!({"createLayers": true}), "layers"),
|
||||
(json!({"output": {"conversion": "destination"}}), "conversion"),
|
||||
(json!({"output": {"profiles": "all"}}), "ICC"),
|
||||
(json!({"output": {"trapped": true}}), "trapped"),
|
||||
(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"),
|
||||
(json!({"security": {"printing": "low"}}), "permissions"),
|
||||
] {
|
||||
let w = settings(v.clone()).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) warn about nothing.
|
||||
// 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
|
||||
|
||||
@@ -55,6 +55,8 @@ const WEIGHT_LABELS: [&str; 3] = ["0.125 pt", "0.25 pt", "0.5 pt"];
|
||||
|
||||
/// The Destination entry for the document's own profile (stored as "").
|
||||
const DOCUMENT_PROFILE: &str = "Document profile";
|
||||
/// The Output Intent Profile entry for no output intent (stored as "").
|
||||
const NO_PROFILE: &str = "None";
|
||||
|
||||
const LABEL_WIDTH: f32 = 150.0;
|
||||
/// Width of the section list (its frame adds 6 px a side).
|
||||
@@ -523,38 +525,44 @@ fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
});
|
||||
}
|
||||
|
||||
/// A dropdown of the colour profiles (`names`, the first one standing for "" at `path`), showing
|
||||
/// the profile at `path` (a name that isn't one of them too).
|
||||
fn profile_pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, names: &[&str], enabled: bool) {
|
||||
let blank = names.first().copied().unwrap_or_default();
|
||||
let current = get(d, path).as_str().filter(|s| !s.is_empty()).unwrap_or(blank).to_string();
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
if let Some(i) = widgets::dropdown(ui, path, ¤t, names, 300.0) {
|
||||
set(d, path, json!(if i == 0 { "" } else { names.get(i).copied().unwrap_or_default() }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn output(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let profiles = vectorcraft_color::cms::profiles();
|
||||
let names = |blank: &'static str| -> Vec<&str> { std::iter::once(blank).chain(profiles.iter().map(|p| p.name.as_str())).collect() };
|
||||
heading(ui, "Color");
|
||||
row(ui, "Color conversion:", |ui| {
|
||||
pick::<ColorConversion>(ui, d, "output.conversion", 300.0, |_| true);
|
||||
});
|
||||
let converting = get(d, "output.conversion") != ColorConversion::None.id();
|
||||
row(ui, "Destination:", |ui| {
|
||||
let profiles = vectorcraft_color::cms::profiles();
|
||||
let names: Vec<&str> = std::iter::once(DOCUMENT_PROFILE).chain(profiles.iter().map(|p| p.name.as_str())).collect();
|
||||
let current = get(d, "output.destination").as_str().filter(|s| !s.is_empty()).unwrap_or(DOCUMENT_PROFILE).to_string();
|
||||
ui.add_enabled_ui(converting, |ui| {
|
||||
if let Some(i) = widgets::dropdown(ui, "output.destination", ¤t, &names, 300.0) {
|
||||
set(d, "output.destination", json!(if i == 0 { "" } else { names.get(i).copied().unwrap_or_default() }));
|
||||
}
|
||||
});
|
||||
});
|
||||
row(ui, "Destination:", |ui| profile_pick(ui, d, "output.destination", &names(DOCUMENT_PROFILE), converting));
|
||||
row(ui, "Profile inclusion:", |ui| {
|
||||
pick::<ProfileInclusion>(ui, d, "output.profiles", 300.0, |_| true);
|
||||
});
|
||||
heading(ui, "PDF/X");
|
||||
let pdfx = matches!(choice::<Standard>(d, "standard"), Some(Standard::PdfX1a | Standard::PdfX3 | Standard::PdfX4));
|
||||
heading(ui, "Output Intent");
|
||||
// PDF/A files carry their own output intent.
|
||||
let own = choice::<Standard>(d, "standard") != Some(Standard::PdfA2b);
|
||||
row(ui, "Output intent profile:", |ui| profile_pick(ui, d, "output.outputIntent", &names(NO_PROFILE), own));
|
||||
for (p, label) in [
|
||||
("output.outputIntent", "Output intent profile:"),
|
||||
("output.outputCondition", "Output condition:"),
|
||||
("output.outputConditionId", "Condition identifier:"),
|
||||
("output.registry", "Registry name:"),
|
||||
] {
|
||||
row(ui, label, |ui| text(ui, d, p, pdfx));
|
||||
row(ui, label, |ui| text(ui, d, p, own));
|
||||
}
|
||||
flag(ui, d, "output.trapped", "Mark as trapped", pdfx);
|
||||
if !pdfx {
|
||||
note(ui, "These apply with a PDF/X standard.");
|
||||
flag(ui, d, "output.trapped", "Mark as trapped", own);
|
||||
if !own {
|
||||
note(ui, "PDF/A files carry their own output intent.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ mod tests_overprint;
|
||||
#[cfg(test)]
|
||||
mod tests_paintchips;
|
||||
#[cfg(test)]
|
||||
mod tests_pdfoutput;
|
||||
#[cfg(test)]
|
||||
mod tests_place;
|
||||
#[cfg(test)]
|
||||
mod tests_recolor;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! 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;
|
||||
|
||||
use crate::tests_svg::app;
|
||||
use crate::{dialogs, theme};
|
||||
|
||||
/// One headless frame of the dialog layer.
|
||||
fn frame(app: &mut crate::VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| dialogs::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
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();
|
||||
let fields = &mut app.ui.dialog.as_mut().unwrap().fields;
|
||||
fields.insert("__section".into(), json!("Output"));
|
||||
fields.insert(
|
||||
"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.
|
||||
for (standard, intent) in [("pdfA2b", GENERIC_CMYK), ("none", "Registered Press")] {
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("standard".into(), json!(standard));
|
||||
d.fields["output"]["outputIntent"] = json!(intent);
|
||||
frame(&mut app);
|
||||
}
|
||||
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");
|
||||
}
|
||||
+16
@@ -799,6 +799,22 @@ 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 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
|
||||
for its colour mode). `profiles` (`all`, `destination`, or `taggedSource` for a document assigned profiles with
|
||||
`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). `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},"advanced":{"outlineText":false}}}}
|
||||
```
|
||||
|
||||
## Scale Strokes & Effects
|
||||
|
||||
`object.scale`, `object.transform` and `object.transformEach` take `strokes?: bool` (Scale Strokes & Effects) and
|
||||
|
||||
Reference in new issue
Block a user