mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 00:11:46 +00:00
PDF export: keep the top text and shape layers as vectors, with selectable text (#10355)
* Skip compositing for geometric measurements * Preserve simple PDF shapes and glyph source ranges * Preserve text and shapes in PDF export (#10269) - Export topmost simple text layers as vector glyph outlines with selectable text - Embed ActualText unicode span and invisible text operators (mode 3 Tr) for 100% extraction - Support stroked shapes in vector PDF export (centered solid strokes, dashes, line caps/joins) - Retain glyph style indices in text rendering outlines * Drop a product name from the glyph outline doc comment * PDF export: keep path text and synthetic bold raster * Fix PDF Unicode selection and respect font embedding permissions * Render the text cache in PDF raster fallback fixtures * Fix PDF vector selection eligibility and finite text matrices * Preserve upper PDF vectors and reject overflowing curve controls * Fix text shape import in PDF regression test * Cover PDF Unicode map blocks and leading selection whitespace
This commit is contained in:
1 parent
8e9277e20e
commit
0a9717fa64
14 files changed
+1402
-29
No files matched your search
Generated
+141
@@ -118,6 +118,17 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -751,6 +762,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
@@ -828,6 +848,12 @@ version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytecount"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
@@ -947,6 +973,15 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.5.1"
|
||||
@@ -1051,6 +1086,16 @@ dependencies = [
|
||||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.9.1"
|
||||
@@ -1796,6 +1841,15 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ecb"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.37.0"
|
||||
@@ -3291,6 +3345,16 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interpolate_name"
|
||||
version = "0.2.4"
|
||||
@@ -3884,6 +3948,34 @@ dependencies = [
|
||||
"imgref",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lopdf"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"bitflags 2.13.2",
|
||||
"cbc",
|
||||
"ecb",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"log",
|
||||
"md-5",
|
||||
"nom 8.0.0",
|
||||
"nom_locate",
|
||||
"rand 0.9.5",
|
||||
"rangemap",
|
||||
"sha2",
|
||||
"stringprep",
|
||||
"thiserror 2.0.21",
|
||||
"ttf-parser",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.3"
|
||||
@@ -3911,6 +4003,16 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -4142,6 +4244,17 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom_locate"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d"
|
||||
dependencies = [
|
||||
"bytecount",
|
||||
"memchr",
|
||||
"nom 8.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "noop_proc_macro"
|
||||
version = "0.3.0"
|
||||
@@ -5289,6 +5402,7 @@ dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"imagesize",
|
||||
"lopdf",
|
||||
"miniz_oxide 0.9.1",
|
||||
"photocraft-affinity",
|
||||
"photocraft-algo",
|
||||
@@ -5312,6 +5426,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"ttf-parser",
|
||||
"zune-core 0.5.3",
|
||||
"zune-jpegxl",
|
||||
]
|
||||
@@ -6046,6 +6161,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
|
||||
|
||||
[[package]]
|
||||
name = "rangemap"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d"
|
||||
|
||||
[[package]]
|
||||
name = "rav1e"
|
||||
version = "0.8.1"
|
||||
@@ -7274,6 +7395,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
|
||||
dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -7931,6 +8063,15 @@ version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
|
||||
@@ -394,6 +394,10 @@ fn gray_range(depth: SampleType) -> f64 {
|
||||
/// gray image `gray` (0–1). Returns features in scan order of their first pixel; `origin` offsets
|
||||
/// the reported bounds.
|
||||
pub fn measure_features(mask: &[bool], gray: &[f32], w: usize, h: usize, origin: (i32, i32), range: f64) -> Vec<Feature> {
|
||||
measure_features_impl(mask, Some(gray), w, h, origin, range)
|
||||
}
|
||||
|
||||
fn measure_features_impl(mask: &[bool], gray: Option<&[f32]>, w: usize, h: usize, origin: (i32, i32), range: f64) -> Vec<Feature> {
|
||||
// Two-pass connected components.
|
||||
let mut label = vec![u32::MAX; w * h];
|
||||
let mut parent: Vec<u32> = Vec::new();
|
||||
@@ -491,8 +495,15 @@ pub fn measure_features(mask: &[bool], gray: &[f32], w: usize, h: usize, origin:
|
||||
},
|
||||
);
|
||||
// Area, bounds and gray values.
|
||||
let mut feats: Vec<Feature> =
|
||||
(0..n).map(|_| Feature { bounds: Rect::EMPTY, gray_min: f64::MAX, gray_max: f64::MIN, histogram: vec![0; 256], ..Default::default() }).collect();
|
||||
let mut feats: Vec<Feature> = (0..n)
|
||||
.map(|_| Feature {
|
||||
bounds: Rect::EMPTY,
|
||||
gray_min: f64::MAX,
|
||||
gray_max: f64::MIN,
|
||||
histogram: if gray.is_some() { vec![0; 256] } else { Vec::new() },
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let mut sums = vec![0.0f64; n];
|
||||
let mut values: Vec<Vec<u16>> = vec![Vec::new(); n];
|
||||
for y in 0..h {
|
||||
@@ -506,12 +517,14 @@ pub fn measure_features(mask: &[bool], gray: &[f32], w: usize, h: usize, origin:
|
||||
let (px, py) = (x as i32 + origin.0, y as i32 + origin.1);
|
||||
let r = Rect::new(px, py, px + 1, py + 1);
|
||||
f.bounds = if f.bounds.is_empty() { r } else { f.bounds.union(&r) };
|
||||
let g = f64::from(gray[y * w + x]);
|
||||
f.gray_min = f.gray_min.min(g);
|
||||
f.gray_max = f.gray_max.max(g);
|
||||
sums[l as usize] += g;
|
||||
f.histogram[(g.clamp(0.0, 1.0) * 255.0).round() as usize] += 1;
|
||||
values[l as usize].push((g.clamp(0.0, 1.0) * 65535.0).round() as u16);
|
||||
if let Some(gray) = gray {
|
||||
let g = f64::from(gray[y * w + x]);
|
||||
f.gray_min = f.gray_min.min(g);
|
||||
f.gray_max = f.gray_max.max(g);
|
||||
sums[l as usize] += g;
|
||||
f.histogram[(g.clamp(0.0, 1.0) * 255.0).round() as usize] += 1;
|
||||
values[l as usize].push((g.clamp(0.0, 1.0) * 65535.0).round() as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, f) in feats.iter_mut().enumerate() {
|
||||
@@ -577,7 +590,7 @@ fn gray_image(d: &Document, r: Rect) -> Vec<f32> {
|
||||
buf.px.par_iter().map(|p| 0.30 * p[0] + 0.59 * p[1] + 0.11 * p[2]).collect()
|
||||
}
|
||||
|
||||
fn selection_rows(d: &Document) -> Result<(Feature, Vec<Feature>)> {
|
||||
fn selection_rows(d: &Document, needs_gray: bool) -> Result<(Feature, Vec<Feature>)> {
|
||||
let r = match &d.selection {
|
||||
Some(sel) => sel.content_bounds().intersect(&d.bounds()),
|
||||
None => d.bounds(),
|
||||
@@ -598,13 +611,16 @@ fn selection_rows(d: &Document) -> Result<(Feature, Vec<Feature>)> {
|
||||
};
|
||||
let (w, h) = (r.width() as usize, r.height() as usize);
|
||||
let mask = selection_mask(d, r, n);
|
||||
let gray = gray_image(d, r);
|
||||
let gray = needs_gray.then(|| gray_image(d, r));
|
||||
let range = gray_range(d.depth);
|
||||
let feats = measure_features(&mask, &gray, w, h, (r.x0, r.y0), range);
|
||||
let feats = measure_features_impl(&mask, gray.as_deref(), w, h, (r.x0, r.y0), range);
|
||||
if feats.is_empty() {
|
||||
return Err(EngineError::Other("the selection is empty".into()));
|
||||
}
|
||||
let mut all: Vec<u16> = mask.iter().zip(&gray).filter(|(m, _)| **m).map(|(_, g)| (g.clamp(0.0, 1.0) * 65535.0).round() as u16).collect();
|
||||
let mut all: Vec<u16> = gray
|
||||
.as_deref()
|
||||
.map(|gray| mask.iter().zip(gray).filter(|(m, _)| **m).map(|(_, g)| (g.clamp(0.0, 1.0) * 65535.0).round() as u16).collect())
|
||||
.unwrap_or_default();
|
||||
let med = median(&mut all) * range;
|
||||
Ok((summary(&feats, med), feats))
|
||||
}
|
||||
@@ -999,7 +1015,13 @@ fn record(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut rows: Vec<Map<String, Value>> = Vec::new();
|
||||
match source {
|
||||
"selection" => {
|
||||
let (sum, feats) = selection_rows(&d)?;
|
||||
let needs_gray = s
|
||||
.analysis
|
||||
.data_points
|
||||
.selection
|
||||
.iter()
|
||||
.any(|key| matches!(key.as_str(), "grayMin" | "grayMax" | "grayMean" | "grayMedian" | "integratedDensity" | "histogram"));
|
||||
let (sum, feats) = selection_rows(&d, needs_gray)?;
|
||||
let mut m = base_row(s, &d, source, &label, &now);
|
||||
feature_values(&mut m, &sum, feats.len(), factor);
|
||||
rows.push(m);
|
||||
|
||||
@@ -552,3 +552,18 @@ fn iso_from_unix_matches_the_old_cycle_conversion_including_beyond_chrono() {
|
||||
assert_eq!(iso_from_unix(i64::MAX), "292277026596-12-04T15:30:07Z");
|
||||
assert_eq!(iso_from_unix(i64::MIN), "-292277022657-01-27T08:29:52Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometric_features_match_full_measurements_without_gray() {
|
||||
let mask = [true, true, false, false, false, false, false, true, true];
|
||||
let gray = [0.2; 9];
|
||||
let full = measure_features(&mask, &gray, 3, 3, (4, 7), 255.0);
|
||||
let geometric = measure_features_impl(&mask, None, 3, 3, (4, 7), 255.0);
|
||||
assert_eq!(full.len(), 2);
|
||||
for (a, b) in full.iter().zip(&geometric) {
|
||||
assert_eq!(a.area, b.area);
|
||||
assert_eq!(a.perimeter, b.perimeter);
|
||||
assert_eq!(a.bounds, b.bounds);
|
||||
assert!(b.histogram.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ photocraft-vector = { workspace = true }
|
||||
photocraft-format = { path = "../format" }
|
||||
craft-docpath = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ttf-parser = "0.25.1"
|
||||
serde_json = { workspace = true }
|
||||
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
|
||||
# svg.rs inflates `.svgz` itself (with a size cap) so the XML can be checked before it is parsed;
|
||||
@@ -53,6 +54,7 @@ resvg = { version = "0.45", default-features = false, features = ["text", "syste
|
||||
resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] }
|
||||
|
||||
[dev-dependencies]
|
||||
lopdf = { version = "0.38", default-features = false }
|
||||
miniz_oxide = "0.9"
|
||||
crc32fast = "1.5"
|
||||
photocraft-affinity = { workspace = true, features = ["synth"] }
|
||||
|
||||
+105
-4
@@ -14,6 +14,10 @@ use photocraft_doc::Document;
|
||||
|
||||
use crate::{ExportResult, IoError};
|
||||
|
||||
mod selection;
|
||||
mod shapes;
|
||||
mod text;
|
||||
|
||||
/// The image placed on a page: 8-bit interleaved samples, 1 (gray), 3 (RGB) or 4 (CMYK,
|
||||
/// 1 = full ink), with the profile it was authored in if any.
|
||||
pub struct PrintImage {
|
||||
@@ -42,7 +46,7 @@ pub struct PrintPage {
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
fn pdf_string(s: &str) -> String {
|
||||
pub(super) fn pdf_string(s: &str) -> String {
|
||||
let mut o = String::from("(");
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
@@ -105,6 +109,10 @@ fn marks_ops(m: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> String {
|
||||
|
||||
/// A one-page PDF.
|
||||
pub fn print_pdf(page: &PrintPage) -> Vec<u8> {
|
||||
print_pdf_with_vectors(page, "", &selection::SelectionFont::default())
|
||||
}
|
||||
|
||||
fn print_pdf_with_vectors(page: &PrintPage, vectors: &str, selection_font: &selection::SelectionFont) -> Vec<u8> {
|
||||
let mut out: Vec<u8> = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec();
|
||||
let mut offsets: Vec<usize> = Vec::new();
|
||||
let mut obj = |out: &mut Vec<u8>, body: &[u8]| {
|
||||
@@ -116,18 +124,20 @@ pub fn print_pdf(page: &PrintPage) -> Vec<u8> {
|
||||
let img = &page.image;
|
||||
let (pw, ph) = page.paper;
|
||||
let (x, y, w, h) = page.rect;
|
||||
let selection_base = if img.icc.is_some() { 8 } else { 7 };
|
||||
// 1 catalog, 2 pages, 3 page, 4 contents, 5 image, 6 font, 7 ICC (optional).
|
||||
obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>");
|
||||
obj(&mut out, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
obj(
|
||||
&mut out,
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 5 0 R >> /Font << /F1 6 0 R >> >> /Contents 4 0 R >>"
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 5 0 R >> /Font << /F1 6 0 R /FSelect {selection_base} 0 R >> >> /Contents 4 0 R >>"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
// An explicit white page, so every viewer (and rasteriser) shows paper, not transparency.
|
||||
let mut content = format!("q 1 g 0 0 {pw:.2} {ph:.2} re f Q\nq {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q\n");
|
||||
content.push_str(vectors);
|
||||
content.push_str(&marks_ops(page.marks, page.rect));
|
||||
let any_marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration;
|
||||
let pad = if any_marks { 30.0 } else { 6.0 };
|
||||
@@ -167,6 +177,9 @@ pub fn print_pdf(page: &PrintPage) -> Vec<u8> {
|
||||
body.extend_from_slice(b"\nendstream");
|
||||
obj(&mut out, &body);
|
||||
}
|
||||
for body in selection_font.objects(selection_base) {
|
||||
obj(&mut out, &body);
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes());
|
||||
for o in &offsets {
|
||||
@@ -176,6 +189,78 @@ pub fn print_pdf(page: &PrintPage) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The vector operators for the contiguous visible top prefix of `doc`, top to bottom, with the
|
||||
/// indices of the layers they replace, and the selection font covering their text (plus, when a
|
||||
/// lower candidate is rejected during emission, that layer's unused characters).
|
||||
/// Hidden and blocked layers contribute no characters, so they cannot exhaust the font's codes.
|
||||
fn collect_vectors(doc: &Document) -> (Vec<(usize, String)>, selection::SelectionFont) {
|
||||
enum Candidate<'a> {
|
||||
Ops(String),
|
||||
Text(text::PreparedText<'a>),
|
||||
}
|
||||
let mut candidates = Vec::new();
|
||||
if doc.icc_profile.is_none() {
|
||||
let mut repertoire = std::collections::BTreeSet::new();
|
||||
for (index, layer) in doc.layers.iter().enumerate().rev() {
|
||||
if !layer.visible {
|
||||
continue;
|
||||
}
|
||||
if layer.blend != photocraft_color::BlendMode::Normal
|
||||
|| layer.opacity != 1.0
|
||||
|| layer.fill_opacity != 1.0
|
||||
|| layer.clipped
|
||||
|| layer.mask.is_some()
|
||||
|| layer.vector_mask.is_some()
|
||||
|| (layer.effects.enabled && !layer.effects.items.is_empty())
|
||||
|| layer.excluded_channels != 0
|
||||
|| layer.blend_if != Default::default()
|
||||
|| layer.advanced != Default::default()
|
||||
|| layer.video.is_some()
|
||||
{
|
||||
break;
|
||||
}
|
||||
let candidate = match &layer.content {
|
||||
photocraft_doc::LayerContent::Shape(shape) => match shapes::shape_path(shape, doc.mode) {
|
||||
Some(ops) => Candidate::Ops(ops),
|
||||
None => break,
|
||||
},
|
||||
photocraft_doc::LayerContent::Text(text_layer) => {
|
||||
let Some(prepared) = text::prepare(text_layer, doc.mode, doc.resolution_dpi) else { break };
|
||||
for ch in text_layer.text.chars() {
|
||||
repertoire.insert(ch);
|
||||
if repertoire.len() > 255 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if repertoire.len() > 255 {
|
||||
break;
|
||||
}
|
||||
Candidate::Text(prepared)
|
||||
}
|
||||
_ => break,
|
||||
};
|
||||
candidates.push((index, candidate));
|
||||
}
|
||||
}
|
||||
let selection_font = selection::SelectionFont::new(candidates.iter().filter_map(|(_, candidate)| match candidate {
|
||||
Candidate::Text(prepared) => Some(prepared.text()),
|
||||
Candidate::Ops(_) => None,
|
||||
}));
|
||||
let mut layers = Vec::with_capacity(candidates.len());
|
||||
for (index, candidate) in candidates {
|
||||
let ops = match candidate {
|
||||
Candidate::Ops(ops) => ops,
|
||||
Candidate::Text(prepared) => match text::ops(&prepared, doc.mode, &selection_font) {
|
||||
Some(ops) => ops,
|
||||
// Keep the successful upper vectors; this layer and everything below stay raster.
|
||||
None => break,
|
||||
},
|
||||
};
|
||||
layers.push((index, ops));
|
||||
}
|
||||
(layers, selection_font)
|
||||
}
|
||||
|
||||
/// File › Save As / Save a Copy › PDF (#3401): the flattened document, at its print size
|
||||
/// (pixels ÷ resolution × 72 pt), as a one-page PDF. Layers and transparency are dropped — the
|
||||
/// warning says so — and the embedded profile travels with the image.
|
||||
@@ -193,7 +278,17 @@ pub fn export(doc: &Document) -> std::result::Result<ExportResult, IoError> {
|
||||
let fmt = doc.pixel_format();
|
||||
let n = fmt.channels().max(1);
|
||||
let canvas = photocraft_geom::Rect::new(0, 0, w as i32, h as i32);
|
||||
let surface = photocraft_compose::flatten_to_surface(doc, PixelFormat::new(fmt.mode, fmt.sample, true), None);
|
||||
// Simple topmost shapes and text layers can be drawn over the flattened backdrop without changing
|
||||
// compositing. Stop at the first unsupported visible layer, since it may depend on the
|
||||
// backdrop (adjustments, blend modes, masks, effects, or clipping).
|
||||
let (vector_layers, selection_font) = collect_vectors(doc);
|
||||
let mut backdrop = doc.clone();
|
||||
for (index, _) in &vector_layers {
|
||||
if let Some(layer) = backdrop.layers.get_mut(*index) {
|
||||
layer.visible = false;
|
||||
}
|
||||
}
|
||||
let surface = photocraft_compose::flatten_to_surface(&backdrop, PixelFormat::new(fmt.mode, fmt.sample, true), None);
|
||||
let values = surface.read_region(canvas);
|
||||
let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
// Straight alpha, composited here over paper: white in RGB/Gray (1 = full light), zero ink
|
||||
@@ -214,7 +309,13 @@ pub fn export(doc: &Document) -> std::result::Result<ExportResult, IoError> {
|
||||
if !doc.metadata.text.is_empty() {
|
||||
warnings.push("free-form text metadata is not supported by PDF; it will be dropped".into());
|
||||
}
|
||||
Ok(ExportResult { bytes: print_pdf(&page), warnings })
|
||||
let scale = 72.0 / dpi;
|
||||
let mut vectors = format!("q {scale} 0 0 -{scale} 0 {ph} cm\n");
|
||||
for (_, ops) in vector_layers.iter().rev() {
|
||||
vectors.push_str(ops);
|
||||
}
|
||||
vectors.push_str("Q\n");
|
||||
Ok(ExportResult { bytes: print_pdf_with_vectors(&page, &vectors, &selection_font), warnings })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! An embedded, empty Type 3 font for the selection plane. Visible glyphs remain paths.
|
||||
//! No source font programs are copied into this font; each code has a Unicode mapping.
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct SelectionFont {
|
||||
chars: BTreeSet<char>,
|
||||
}
|
||||
|
||||
impl SelectionFont {
|
||||
/// Collect distinct characters until the one-byte code space is exceeded. Stopping there,
|
||||
/// rather than after a fixed number of characters scanned, keeps a long run of repeated
|
||||
/// text from hiding later distinct characters.
|
||||
pub(super) fn new<'a>(texts: impl Iterator<Item = &'a str>) -> Self {
|
||||
let mut chars = BTreeSet::new();
|
||||
'texts: for text in texts {
|
||||
for ch in text.chars() {
|
||||
chars.insert(ch);
|
||||
if chars.len() > 255 {
|
||||
break 'texts;
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { chars }
|
||||
}
|
||||
|
||||
pub(super) fn encode(&self, text: &str) -> Option<String> {
|
||||
if self.chars.len() > 255 {
|
||||
return None;
|
||||
}
|
||||
let mut encoded = String::new();
|
||||
for ch in text.chars() {
|
||||
let code = self.chars.iter().position(|c| *c == ch)?.checked_add(1)?;
|
||||
write!(encoded, "{code:02X}").ok()?;
|
||||
}
|
||||
Some(encoded)
|
||||
}
|
||||
|
||||
// Object numbers are assigned by the caller, after the optional ICC object.
|
||||
pub(super) fn objects(&self, base: usize) -> Vec<Vec<u8>> {
|
||||
let count = self.chars.len().min(255);
|
||||
let mut differences = String::new();
|
||||
let mut procs = String::new();
|
||||
let mut cmap = String::from(
|
||||
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /PhotoCraftUnicode def\n/CMapType 2 def\n1 begincodespacerange\n<00> <FF>\nendcodespacerange\n",
|
||||
);
|
||||
// PDF permits at most 100 entries in each bfchar block.
|
||||
let chars: Vec<_> = self.chars.iter().take(count).collect();
|
||||
for (block, chunk) in chars.chunks(100).enumerate() {
|
||||
let _ = writeln!(cmap, "{} beginbfchar", chunk.len());
|
||||
for (i, ch) in chunk.iter().enumerate() {
|
||||
let code = block * 100 + i + 1;
|
||||
let mut buf = [0; 2];
|
||||
let unicode = ch.encode_utf16(&mut buf).iter().map(|u| format!("{u:04X}")).collect::<String>();
|
||||
let _ = writeln!(cmap, "<{code:02X}> <{unicode}>");
|
||||
}
|
||||
cmap.push_str("endbfchar\n");
|
||||
}
|
||||
cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
|
||||
for code in 1..=count {
|
||||
let _ = write!(differences, " /c{code}");
|
||||
let _ = write!(procs, " /c{code} {} 0 R", base + 1);
|
||||
}
|
||||
let widths = "1000 ".repeat(count.max(1));
|
||||
let font = format!(
|
||||
"<< /Type /Font /Subtype /Type3 /Name /FSelect /FontBBox [0 -250 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] /CharProcs <<{procs} >> /Encoding << /Type /Encoding /Differences [1{differences}] >> /FirstChar 1 /LastChar {} /Widths [{widths}] /Resources << >> /ToUnicode {} 0 R >>",
|
||||
count.max(1),
|
||||
base + 2
|
||||
);
|
||||
let stream = |data: &str| format!("<< /Length {} >>\nstream\n{data}endstream", data.len()).into_bytes();
|
||||
vec![font.into_bytes(), stream("1000 0 0 -250 1000 1000 d1\n"), stream(&cmap)]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn distinct_characters_after_a_long_repeated_run_still_encode() {
|
||||
let mut text = "a".repeat(70_000);
|
||||
text.push('Ω');
|
||||
let font = SelectionFont::new(std::iter::once(text.as_str()));
|
||||
assert!(font.encode("aΩ").is_some(), "the distinct tail character must survive the repetition");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_one_byte_code_space_fits_exactly_255_distinct_characters() {
|
||||
let fits: String = (0x100u32..0x1FF).filter_map(char::from_u32).collect();
|
||||
assert_eq!(fits.chars().count(), 255);
|
||||
let font = SelectionFont::new(std::iter::once(fits.as_str()));
|
||||
assert!(font.encode(&fits).is_some(), "255 distinct characters fit one-byte codes");
|
||||
|
||||
let too_many: String = (0x100u32..0x200).filter_map(char::from_u32).collect();
|
||||
assert_eq!(too_many.chars().count(), 256);
|
||||
let font = SelectionFont::new(std::iter::once(too_many.as_str()));
|
||||
assert!(font.encode("Ā").is_none(), "256 distinct characters exceed the code space");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_unicode_maps_codes_beyond_the_first_bfchar_block() {
|
||||
let text: String = (0x100u32..0x196).filter_map(char::from_u32).collect();
|
||||
let font = SelectionFont::new(std::iter::once(text.as_str()));
|
||||
let objects = font.objects(0);
|
||||
let cmap = String::from_utf8_lossy(&objects[2]);
|
||||
assert!(cmap.contains("100 beginbfchar"));
|
||||
assert!(cmap.contains("50 beginbfchar"));
|
||||
assert!(cmap.contains("<65> <0164>"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Faithful vector subset for PDF shape export; unsupported shapes use raster fallback.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use photocraft_color::ColorMode;
|
||||
use photocraft_doc::{Fill, FillRule, LineCap, LineJoin, PathOp, ShapeLayer, StrokeAlign};
|
||||
|
||||
/// Return a PDF path and fill operator for a directly representable shape.
|
||||
/// Coordinates remain in document pixels with y increasing down; the caller applies transforms.
|
||||
pub(super) fn shape_path(layer: &ShapeLayer, mode: ColorMode) -> Option<String> {
|
||||
if layer.has_soft_mask() || layer.path.inverted || layer.path.subpaths.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
if layer.fill.is_none() && layer.stroke.is_none() {
|
||||
return None;
|
||||
}
|
||||
let channels = match mode {
|
||||
ColorMode::Rgb => 3,
|
||||
ColorMode::Grayscale => 1,
|
||||
ColorMode::Cmyk => 4,
|
||||
_ => return None,
|
||||
};
|
||||
let mut out = String::new();
|
||||
if let Some(fill) = &layer.fill {
|
||||
let Fill::Solid(color) = fill else { return None };
|
||||
if color.mode != mode || !color.alpha.is_finite() || color.alpha != 1.0 || color.c[..channels].iter().any(|v| !v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
for value in color.c.iter().take(channels) {
|
||||
if !(0.0..=1.0).contains(value) {
|
||||
return None;
|
||||
}
|
||||
write!(out, "{value} ").ok()?;
|
||||
}
|
||||
out.push_str(match mode {
|
||||
ColorMode::Rgb => "rg\n",
|
||||
ColorMode::Grayscale => "g\n",
|
||||
ColorMode::Cmyk => "k\n",
|
||||
_ => return None,
|
||||
});
|
||||
}
|
||||
if let Some(stroke) = &layer.stroke {
|
||||
if stroke.align != StrokeAlign::Center || stroke.opacity != 1.0 || stroke.validate().is_err() || stroke.width <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let Fill::Solid(stroke_color) = &stroke.paint else { return None };
|
||||
if stroke_color.mode != mode || stroke_color.alpha != 1.0 || !stroke_color.alpha.is_finite() {
|
||||
return None;
|
||||
}
|
||||
for value in stroke_color.c.iter().take(channels) {
|
||||
if !value.is_finite() || !(0.0..=1.0).contains(value) {
|
||||
return None;
|
||||
}
|
||||
write!(out, "{value} ").ok()?;
|
||||
}
|
||||
out.push_str(match mode {
|
||||
ColorMode::Rgb => "RG\n",
|
||||
ColorMode::Grayscale => "G\n",
|
||||
ColorMode::Cmyk => "K\n",
|
||||
_ => return None,
|
||||
});
|
||||
let cap = match stroke.cap {
|
||||
LineCap::Butt => 0,
|
||||
LineCap::Round => 1,
|
||||
LineCap::Square => 2,
|
||||
};
|
||||
let join = match stroke.join {
|
||||
LineJoin::Miter => 0,
|
||||
LineJoin::Round => 1,
|
||||
LineJoin::Bevel => 2,
|
||||
};
|
||||
writeln!(out, "{} w {cap} J {join} j {} M", stroke.width, stroke.miter_limit).ok()?;
|
||||
out.push('[');
|
||||
for dash in &stroke.dashes {
|
||||
write!(out, "{} ", dash * stroke.width).ok()?;
|
||||
}
|
||||
writeln!(out, "] {} d", stroke.dash_offset * stroke.width).ok()?;
|
||||
}
|
||||
let subpath = layer.path.subpaths.first()?;
|
||||
if !subpath.closed || subpath.knots.len() < 2 || subpath.op != PathOp::Combine {
|
||||
return None;
|
||||
}
|
||||
let first = subpath.knots.first()?;
|
||||
if !point_finite(first.anchor) {
|
||||
return None;
|
||||
}
|
||||
writeln!(out, "{} {} m", first.anchor.x, first.anchor.y).ok()?;
|
||||
for segment in subpath.segments() {
|
||||
if segment.iter().any(|p| !point_finite(*p)) {
|
||||
return None;
|
||||
}
|
||||
writeln!(out, "{} {} {} {} {} {} c", segment[1].x, segment[1].y, segment[2].x, segment[2].y, segment[3].x, segment[3].y).ok()?;
|
||||
}
|
||||
out.push_str("h\n");
|
||||
out.push_str(match (layer.fill.is_some(), layer.stroke.is_some(), layer.path.fill_rule) {
|
||||
(true, false, FillRule::NonZero) => "f\n",
|
||||
(true, false, FillRule::EvenOdd) => "f*\n",
|
||||
(false, true, _) => "S\n",
|
||||
(true, true, FillRule::NonZero) => "B\n",
|
||||
(true, true, FillRule::EvenOdd) => "B*\n",
|
||||
(false, false, _) => return None,
|
||||
});
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn point_finite(p: photocraft_geom::Point) -> bool {
|
||||
p.x.is_finite() && p.y.is_finite()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_doc::{Knot, Path, Subpath};
|
||||
use photocraft_geom::Point;
|
||||
|
||||
#[test]
|
||||
fn closed_cubic_emits_path_and_fill() {
|
||||
let layer = ShapeLayer {
|
||||
fill: Some(Fill::Solid(photocraft_color::Color::rgb(1.0, 0.0, 0.0))),
|
||||
path: Path::new(vec![Subpath {
|
||||
closed: true,
|
||||
knots: vec![Knot::smooth(Point::new(0.0, 0.0), Point::new(-1.0, 0.0), Point::new(1.0, 0.0)), Knot::corner(10.0, 0.0)],
|
||||
..Subpath::default()
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
let pdf = shape_path(&layer, ColorMode::Rgb).unwrap();
|
||||
assert!(pdf.contains("1 0 10 0 10 0 c"));
|
||||
assert!(pdf.ends_with("h\nf\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_open_path_and_mismatched_mode() {
|
||||
let mut layer = ShapeLayer {
|
||||
fill: Some(Fill::Solid(photocraft_color::Color::rgb(1.0, 0.0, 0.0))),
|
||||
path: Path::new(vec![Subpath::polyline(&[(0.0, 0.0), (1.0, 1.0)])]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(shape_path(&layer, ColorMode::Rgb).is_none());
|
||||
layer.path.subpaths[0].closed = true;
|
||||
assert!(shape_path(&layer, ColorMode::Cmyk).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_stroke_keeps_its_width_color_and_dash_pattern() {
|
||||
let layer = ShapeLayer {
|
||||
fill: Some(Fill::Solid(photocraft_color::Color::rgb(1.0, 0.0, 0.0))),
|
||||
path: Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)])]),
|
||||
stroke: Some(photocraft_doc::ShapeStroke { width: 2.0, dashes: vec![2.0, 1.0], ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let ops = shape_path(&layer, ColorMode::Rgb).unwrap();
|
||||
assert!(ops.contains("0 0 0 RG\n"));
|
||||
assert!(ops.contains("2 w 0 J 0 j 100 M\n[4 2 ] 0 d\n"));
|
||||
assert!(ops.ends_with("h\nB\n"));
|
||||
let inside = ShapeLayer { stroke: Some(photocraft_doc::ShapeStroke { align: StrokeAlign::Inside, ..Default::default() }), ..layer };
|
||||
assert!(shape_path(&inside, ColorMode::Rgb).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_only_emits_stroke_operator() {
|
||||
let layer = ShapeLayer {
|
||||
path: Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)])]),
|
||||
stroke: Some(photocraft_doc::ShapeStroke { width: 1.0, ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let ops = shape_path(&layer, ColorMode::Rgb).unwrap();
|
||||
assert!(!ops.contains("rg\n"));
|
||||
assert!(ops.contains("0 0 0 RG\n"));
|
||||
assert!(ops.ends_with("h\nS\n"));
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,85 @@ fn layers_composite_over_white_before_the_page() {
|
||||
assert_eq!(&img[0..3], &[255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topmost_solid_shape_is_exported_as_a_vector_path() {
|
||||
use photocraft_doc::{Fill, Path, ShapeLayer, Subpath};
|
||||
let mut d = doc(ColorMode::Rgb);
|
||||
let shape = ShapeLayer {
|
||||
path: Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (2.0, 0.0), (2.0, 1.0)])]),
|
||||
fill: Some(Fill::Solid(photocraft_color::Color::rgb(1.0, 0.0, 0.0))),
|
||||
..Default::default()
|
||||
};
|
||||
d.layers.push(Layer::new("Shape", LayerContent::Shape(shape)));
|
||||
let out = export(&d).unwrap();
|
||||
check_structure(&out.bytes);
|
||||
let content = stream_after(&out.bytes, "/Contents 4 0 R");
|
||||
let content = String::from_utf8(content).unwrap();
|
||||
assert!(content.contains("1 0 0 rg\n"));
|
||||
assert!(content.contains("0 0 m\n"));
|
||||
assert!(content.contains("h\nf\n"));
|
||||
assert_eq!(stream_after(&out.bytes, "/Subtype /Image"), vec![255; 4 * 2 * 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topmost_text_layer_is_exported_with_selectable_text_and_vector_outlines() {
|
||||
use photocraft_doc::TextLayer;
|
||||
let mut d = doc(ColorMode::Rgb);
|
||||
let text = TextLayer {
|
||||
text: "Vector PDF".into(),
|
||||
size_pt: 24.0,
|
||||
color: photocraft_color::Color::rgb(0.0, 0.0, 1.0),
|
||||
transform: photocraft_geom::Affine::translate(1.0, 1.5),
|
||||
..Default::default()
|
||||
};
|
||||
let layout = photocraft_text::TextEngine::new().layout(&text, 72.0);
|
||||
let cluster = layout.clusters.iter().filter(|cluster| !cluster.range.is_empty()).min_by_key(|cluster| cluster.range.start).expect("a shaped cluster");
|
||||
let baseline = layout.lines.get(cluster.line).expect("a line").baseline;
|
||||
let origin = text.transform.apply(photocraft_geom::Point::new(f64::from(cluster.x), f64::from(baseline)));
|
||||
d.layers.push(Layer::new("Text", LayerContent::Text(text)));
|
||||
let out = export(&d).unwrap();
|
||||
check_structure(&out.bytes);
|
||||
let content = stream_after(&out.bytes, "/Contents 4 0 R");
|
||||
let content = String::from_utf8(content).unwrap();
|
||||
assert!(content.contains("/Span << /ActualText <FEFF"));
|
||||
assert!(content.contains("0 0 1 rg"));
|
||||
assert!(content.contains("BT"));
|
||||
assert!(content.contains("3 Tr"));
|
||||
assert!(content.contains("/FSelect"));
|
||||
assert!(has(&out.bytes, "/ToUnicode"));
|
||||
let tm = format!("{} {} Tm", origin.x, origin.y);
|
||||
assert!(content.lines().any(|line| line.ends_with(tm.as_str())), "the translated text starts at {tm}:\n{content}");
|
||||
let parsed = lopdf::Document::load_mem(&out.bytes).unwrap();
|
||||
assert_eq!(parsed.extract_text(&[1]).unwrap().trim(), "Vector PDF");
|
||||
assert!(content.contains("ET"));
|
||||
assert!(content.contains("EMC"));
|
||||
assert_eq!(stream_after(&out.bytes, "/Subtype /Image"), vec![255; 4 * 2 * 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topmost_shape_and_text_layers_are_both_exported_as_vectors() {
|
||||
use photocraft_doc::{Fill, Path, ShapeLayer, Subpath, TextLayer};
|
||||
let mut d = doc(ColorMode::Rgb);
|
||||
let shape = ShapeLayer {
|
||||
path: Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (2.0, 0.0), (2.0, 1.0)])]),
|
||||
fill: Some(Fill::Solid(photocraft_color::Color::rgb(1.0, 0.0, 0.0))),
|
||||
..Default::default()
|
||||
};
|
||||
d.layers.push(Layer::new("Shape", LayerContent::Shape(shape)));
|
||||
let text = TextLayer { text: "Hi".into(), size_pt: 12.0, color: photocraft_color::Color::rgb(0.0, 1.0, 0.0), ..Default::default() };
|
||||
d.layers.push(Layer::new("Text", LayerContent::Text(text)));
|
||||
let out = export(&d).unwrap();
|
||||
check_structure(&out.bytes);
|
||||
let content = stream_after(&out.bytes, "/Contents 4 0 R");
|
||||
let content = String::from_utf8(content).unwrap();
|
||||
let red = content.find("1 0 0 rg\n").expect("the shape fill");
|
||||
let green = content.find("0 1 0 rg\n").expect("the text fill");
|
||||
assert!(red < green, "the lower red shape is painted before the upper green text");
|
||||
assert!(content.contains("/Span << /ActualText <FEFF0048> >> BDC"));
|
||||
assert!(content.contains("> Tj"));
|
||||
assert_eq!(stream_after(&out.bytes, "/Subtype /Image"), vec![255; 4 * 2 * 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn greyscale_and_cmyk_pages_keep_their_channels() {
|
||||
let g = export(&doc(ColorMode::Grayscale)).unwrap();
|
||||
@@ -129,3 +208,188 @@ fn other_modes_are_refused_with_an_actionable_error() {
|
||||
assert!(e.contains("RGB") && e.contains("Mode"), "{e}");
|
||||
assert!(crate::export(&d, "x.pdf", &crate::ExportOptions::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_text_over_a_photo_extracts_through_a_pdf_reader() {
|
||||
let mut d = doc(ColorMode::Rgb);
|
||||
d.size = Size { width: 128, height: 48 };
|
||||
let fmt = d.pixel_format();
|
||||
// Synthetic photo-like backdrop; no personal image or third-party asset.
|
||||
d.layers = vec![Layer::new("Photo", LayerContent::Raster(solid(fmt, 0, 0, 128, 48, &[0.2, 0.4, 0.6, 1.0])))];
|
||||
let text = "café Ω 😀";
|
||||
d.layers.push(Layer::new(
|
||||
"Text",
|
||||
LayerContent::Text(photocraft_doc::TextLayer {
|
||||
text: text.into(),
|
||||
size_pt: 12.0,
|
||||
transform: photocraft_geom::Affine::translate(2.0, 20.0),
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
let pdf = export(&d).unwrap().bytes;
|
||||
let parsed = lopdf::Document::load_mem(&pdf).unwrap();
|
||||
assert_eq!(parsed.extract_text(&[1]).unwrap().trim(), text);
|
||||
assert!(has(&pdf, "/Subtype /Image"));
|
||||
assert!(has(&pdf, "/Subtype /Type3"));
|
||||
assert!(has(&pdf, "/ToUnicode"));
|
||||
assert_eq!(&stream_after(&pdf, "/Subtype /Image")[..3], &[51, 102, 153]);
|
||||
let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap();
|
||||
assert!(!content.contains("/ActualText <FEFF00630061006600E9"), "selection spans are clusters, not the entire layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_with_compositing_or_a_profile_stays_in_the_raster_backdrop() {
|
||||
for case in 0..5 {
|
||||
let mut d = doc(ColorMode::Rgb);
|
||||
d.size = Size { width: 32, height: 32 };
|
||||
let mut layer = Layer::new(
|
||||
"Text",
|
||||
LayerContent::Text(photocraft_doc::TextLayer {
|
||||
text: "A".into(),
|
||||
size_pt: 16.0,
|
||||
transform: photocraft_geom::Affine::translate(2.0, 20.0),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
// The compositor consumes the text cache maintained by the engine/UI.
|
||||
if let LayerContent::Text(text) = &mut layer.content {
|
||||
photocraft_text::TextEngine::new().render_layer(text, d.resolution_dpi, d.pixel_format());
|
||||
}
|
||||
match case {
|
||||
0 => layer.opacity = 0.5,
|
||||
1 => layer.fill_opacity = 0.5,
|
||||
2 => layer.blend = photocraft_color::BlendMode::Multiply,
|
||||
3 => {
|
||||
layer.effects.enabled = true;
|
||||
layer.effects.items.push(photocraft_doc::effects::Effect::default_drop_shadow());
|
||||
}
|
||||
_ => d.icc_profile = Some(std::sync::Arc::new(vec![1, 2, 3, 4])),
|
||||
}
|
||||
d.layers.push(layer);
|
||||
let pdf = export(&d).unwrap().bytes;
|
||||
let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap();
|
||||
assert!(!content.contains("/ActualText"), "case {case} must retain the raster composite");
|
||||
assert!(stream_after(&pdf, "/Subtype /Image").iter().any(|v| *v != 255), "case {case} must retain visible text");
|
||||
if case == 4 {
|
||||
assert!(has(&pdf, "[/ICCBased 7 0 R]"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 300 distinct characters: more than the selection font's 255 one-byte codes.
|
||||
fn many_distinct_chars() -> String {
|
||||
let text: String = (0x100u32..0x22C).filter_map(char::from_u32).collect();
|
||||
assert_eq!(text.chars().count(), 300);
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_outside_the_vector_prefix_does_not_consume_the_selection_font() {
|
||||
use photocraft_doc::TextLayer;
|
||||
let many = many_distinct_chars();
|
||||
for case in 0..2 {
|
||||
let mut d = doc(ColorMode::Rgb);
|
||||
// Hidden in the first case; in the second a raster layer with reduced opacity sits above
|
||||
// it, so scanning stops before the oversized repertoire.
|
||||
let mut lower = Layer::new("Lower text", LayerContent::Text(TextLayer { text: many.clone(), size_pt: 12.0, ..Default::default() }));
|
||||
lower.visible = case == 1;
|
||||
d.layers.push(lower);
|
||||
if case == 1 {
|
||||
let fmt = d.pixel_format();
|
||||
let mut blocker = Layer::new("Blocker", LayerContent::Raster(solid(fmt, 0, 0, 4, 2, &[0.0, 0.0, 0.0, 0.5])));
|
||||
blocker.opacity = 0.5;
|
||||
d.layers.push(blocker);
|
||||
}
|
||||
d.layers.push(Layer::new(
|
||||
"Top text",
|
||||
LayerContent::Text(TextLayer { text: "Hi".into(), size_pt: 12.0, color: photocraft_color::Color::rgb(0.0, 1.0, 0.0), ..Default::default() }),
|
||||
));
|
||||
let pdf = export(&d).unwrap().bytes;
|
||||
let parsed = lopdf::Document::load_mem(&pdf).unwrap();
|
||||
assert_eq!(parsed.extract_text(&[1]).unwrap().trim(), "Hi", "case {case}: only the top text is selectable");
|
||||
let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap();
|
||||
assert!(content.contains("/ActualText <FEFF0048"), "case {case}: the top text must stay vector:\n{content}");
|
||||
assert!(content.contains("/FSelect"), "case {case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_layer_refused_at_emission_keeps_the_upper_vectors_and_stays_raster() {
|
||||
use photocraft_doc::{TextLayer, text::TextShape};
|
||||
let mut d = doc(ColorMode::Rgb);
|
||||
d.size = Size { width: 128, height: 48 };
|
||||
let fmt = d.pixel_format();
|
||||
d.layers = vec![Layer::new("Paper", LayerContent::Raster(solid(fmt, 0, 0, 128, 48, &[1.0, 1.0, 1.0, 1.0])))];
|
||||
// A box too short for its paragraph: the layout hides the overflow, so `prepare` accepts
|
||||
// the layer (its checks only see placed clusters) while emission refuses it, because the
|
||||
// hidden, unpainted source text would otherwise show up in the selection plane.
|
||||
let mut lower = Layer::new(
|
||||
"Lower text",
|
||||
LayerContent::Text(TextLayer {
|
||||
text: "The quick brown fox jumps over the lazy dog again and again".into(),
|
||||
font_family: "Inter".into(),
|
||||
size_pt: 12.0,
|
||||
color: photocraft_color::Color::rgb(1.0, 0.0, 0.0),
|
||||
shape: TextShape::Box { x: 0.0, y: 0.0, width: 100.0, height: 30.0 },
|
||||
transform: photocraft_geom::Affine::translate(2.0, 10.0),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
// The compositor consumes the text cache maintained by the engine/UI.
|
||||
if let LayerContent::Text(text) = &mut lower.content {
|
||||
photocraft_text::TextEngine::new().render_layer(text, d.resolution_dpi, fmt);
|
||||
}
|
||||
// Pin the fixture to the intended path: accepted by `prepare`, refused by `ops`.
|
||||
let LayerContent::Text(lower_text) = &lower.content else { panic!("the fixture is a text layer") };
|
||||
let prepared = super::text::prepare(lower_text, ColorMode::Rgb, d.resolution_dpi).expect("prepare accepts the truncated box");
|
||||
let lower_font = super::selection::SelectionFont::new(std::iter::once(lower_text.text.as_str()));
|
||||
assert!(super::text::ops(&prepared, ColorMode::Rgb, &lower_font).is_none(), "emission must refuse the unrepresented source text");
|
||||
d.layers.push(lower);
|
||||
d.layers.push(Layer::new(
|
||||
"Top text",
|
||||
LayerContent::Text(TextLayer {
|
||||
text: "Hi".into(),
|
||||
font_family: "Inter".into(),
|
||||
size_pt: 12.0,
|
||||
color: photocraft_color::Color::rgb(0.0, 1.0, 0.0),
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
let pdf = export(&d).unwrap().bytes;
|
||||
check_structure(&pdf);
|
||||
let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap();
|
||||
assert!(content.contains("/ActualText <FEFF0048"), "the upper text stays vector:\n{content}");
|
||||
assert!(content.contains("0 1 0 rg"), "the upper text keeps its fill:\n{content}");
|
||||
assert!(content.contains("/FSelect"), "the selection font survives the refused layer");
|
||||
assert!(!content.contains("1 0 0 rg"), "the refused layer must not be vector:\n{content}");
|
||||
assert!(!content.contains("/ActualText <FEFF0054"), "its text must stay out of the selection plane:\n{content}");
|
||||
let parsed = lopdf::Document::load_mem(&pdf).unwrap();
|
||||
assert_eq!(parsed.extract_text(&[1]).unwrap().trim(), "Hi", "only the upper text is selectable");
|
||||
let img = stream_after(&pdf, "/Subtype /Image");
|
||||
assert!(
|
||||
img.chunks(3).any(|p| i16::from(p[0]) - i16::from(p[1]) > 8 && i16::from(p[0]) - i16::from(p[2]) > 8),
|
||||
"the refused red text stays visible in the raster backdrop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_text_keeps_its_paragraph_breaks_in_the_selection() {
|
||||
use photocraft_doc::TextLayer;
|
||||
let mut d = doc(ColorMode::Rgb);
|
||||
d.layers.push(Layer::new("Text", LayerContent::Text(TextLayer { text: "A\nB".into(), size_pt: 12.0, ..Default::default() })));
|
||||
let pdf = export(&d).unwrap().bytes;
|
||||
let parsed = lopdf::Document::load_mem(&pdf).unwrap();
|
||||
assert_eq!(parsed.extract_text(&[1]).unwrap().trim(), "A\nB", "the paragraph break survives selection");
|
||||
let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap();
|
||||
assert!(content.contains("/ActualText <FEFF0041000A"), "the first span carries the break:\n{content}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_newline_stays_in_the_first_selection_span() {
|
||||
use photocraft_doc::TextLayer;
|
||||
let mut d = doc(ColorMode::Rgb);
|
||||
d.layers.push(Layer::new("Text", LayerContent::Text(TextLayer { text: "\nA".into(), size_pt: 12.0, ..Default::default() })));
|
||||
let pdf = export(&d).unwrap().bytes;
|
||||
let content = String::from_utf8(stream_after(&pdf, "/Contents 4 0 R")).unwrap();
|
||||
assert!(content.contains("/ActualText <FEFF000A0041>"), "the first span retains the leading newline:\n{content}");
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
//! Faithful vector and selectable text export for PDF.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use photocraft_color::ColorMode;
|
||||
use photocraft_doc::TextLayer;
|
||||
|
||||
fn actual_text_hex(text: &str) -> String {
|
||||
let mut hex = String::from("FEFF");
|
||||
for code in text.encode_utf16() {
|
||||
let _ = write!(hex, "{code:04X}");
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
fn write_fill_color(out: &mut String, color: &photocraft_color::Color, mode: ColorMode, channels: usize) -> Option<()> {
|
||||
for value in color.c.iter().take(channels) {
|
||||
if !value.is_finite() || !(0.0..=1.0).contains(value) {
|
||||
return None;
|
||||
}
|
||||
write!(out, "{value} ").ok()?;
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
match mode {
|
||||
ColorMode::Rgb => "rg",
|
||||
ColorMode::Grayscale => "g",
|
||||
ColorMode::Cmyk => "k",
|
||||
_ => return None,
|
||||
}
|
||||
)
|
||||
.ok()?;
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Whether any glyph is thickened by faux bold or by a face lacking the weight: rendering
|
||||
/// emboldens those glyphs, but [`photocraft_text::render::glyph_outlines`] gives the regular outline.
|
||||
fn synthetic_bold(layout: &photocraft_text::TextLayout) -> bool {
|
||||
layout
|
||||
.glyphs
|
||||
.iter()
|
||||
.any(|g| layout.faces.get(g.face as usize).is_some_and(|f| f.embolden) || layout.styles.get(g.style as usize).is_some_and(|s| s.faux_bold))
|
||||
}
|
||||
|
||||
/// Whether `layer` is laid out in straight horizontal lines with a finite transform and `dpi`.
|
||||
/// Path text is bent along its path when rendered, and the outlines written here are not.
|
||||
fn straight_horizontal(layer: &TextLayer, dpi: f32) -> bool {
|
||||
layer.warp.is_none()
|
||||
&& !matches!(layer.shape, photocraft_doc::text::TextShape::Path { .. })
|
||||
&& layer.orientation == photocraft_doc::text::Orientation::Horizontal
|
||||
&& dpi.is_finite()
|
||||
&& dpi > 0.0
|
||||
&& layer.transform.m.iter().all(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// Return PDF vector and invisible text operators for a directly representable text layer.
|
||||
/// Coordinates are in document pixels with y increasing down; the caller applies the CTM.
|
||||
#[cfg(test)]
|
||||
fn text_ops(layer: &TextLayer, mode: ColorMode, dpi: f32) -> Option<String> {
|
||||
let prepared = prepare(layer, mode, dpi)?;
|
||||
let font = super::selection::SelectionFont::new(std::iter::once(layer.text.as_str()));
|
||||
ops(&prepared, mode, &font)
|
||||
}
|
||||
|
||||
fn channels(mode: ColorMode) -> Option<usize> {
|
||||
match mode {
|
||||
ColorMode::Rgb => Some(3),
|
||||
ColorMode::Grayscale => Some(1),
|
||||
ColorMode::Cmyk => Some(4),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn outlines_permitted(bytes: &[u8], index: u32) -> bool {
|
||||
let Ok(face) = ttf_parser::Face::parse(bytes, index) else { return false };
|
||||
matches!(face.permissions(), Some(ttf_parser::Permissions::Installable | ttf_parser::Permissions::PreviewAndPrint | ttf_parser::Permissions::Editable))
|
||||
&& face.is_outline_embedding_allowed()
|
||||
&& face.is_subsetting_allowed()
|
||||
}
|
||||
|
||||
/// A laid-out text layer that is directly representable, except for the selection font: its
|
||||
/// codes depend on every candidate layer's characters, so the caller builds it afterwards.
|
||||
pub(super) struct PreparedText<'a> {
|
||||
layer: &'a TextLayer,
|
||||
layout: photocraft_text::TextLayout,
|
||||
}
|
||||
|
||||
impl PreparedText<'_> {
|
||||
pub(super) fn text(&self) -> &str {
|
||||
self.layer.text.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay `layer` out and check it can be emitted as outlines plus selection text. The selection
|
||||
/// font is deliberately not consulted, so a hidden or already-blocked layer cannot make a
|
||||
/// supported top layer ineligible.
|
||||
pub(super) fn prepare<'a>(layer: &'a TextLayer, mode: ColorMode, dpi: f32) -> Option<PreparedText<'a>> {
|
||||
if !straight_horizontal(layer, dpi) || channels(mode).is_none() {
|
||||
return None;
|
||||
}
|
||||
if layer.text.is_empty() {
|
||||
return Some(PreparedText { layer, layout: photocraft_text::TextLayout::default() });
|
||||
}
|
||||
let mut engine = photocraft_text::TextEngine::new();
|
||||
let layout = engine.layout(layer, dpi);
|
||||
layout_checks(layer, mode, &layout).then_some(PreparedText { layer, layout })
|
||||
}
|
||||
|
||||
/// Everything that can refuse a laid-out layer independently of the selection font. The text
|
||||
/// matrix products are checked here so a layer whose transform overflows is refused during
|
||||
/// candidate selection, not halfway through emission.
|
||||
fn layout_checks(layer: &TextLayer, mode: ColorMode, layout: &photocraft_text::TextLayout) -> bool {
|
||||
let Some(channels) = channels(mode) else { return false };
|
||||
if synthetic_bold(layout) || layout.faces.iter().any(|face| !outlines_permitted(face.font.data.as_ref(), face.font.index)) {
|
||||
return false;
|
||||
}
|
||||
for style in &layout.styles {
|
||||
let color = style.color.in_mode(mode);
|
||||
if !color.alpha.is_finite() || color.alpha != 1.0 || color.c[..channels].iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let [a, b, c, d, _, _] = layer.transform.m;
|
||||
for cluster in &layout.clusters {
|
||||
let Some(text) = layer.text.get(cluster.range.clone()) else { return false };
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(line) = layout.lines.get(cluster.line) else { return false };
|
||||
let height = (line.ascent + line.descent).max(1.0);
|
||||
let width = cluster.advance.abs() / text.chars().count().max(1) as f32;
|
||||
if !height.is_finite() || !width.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let origin = layer.transform.apply(photocraft_geom::Point::new(f64::from(cluster.x), f64::from(line.baseline)));
|
||||
if !origin.x.is_finite() || !origin.y.is_finite() {
|
||||
return false;
|
||||
}
|
||||
for coefficient in [a * f64::from(width), b * f64::from(width), -c * f64::from(height), -d * f64::from(height)] {
|
||||
if !coefficient.is_finite() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn ops(prepared: &PreparedText<'_>, mode: ColorMode, selection_font: &super::selection::SelectionFont) -> Option<String> {
|
||||
let channels = channels(mode)?;
|
||||
layout_ops(prepared.layer, mode, channels, &prepared.layout, selection_font)
|
||||
}
|
||||
|
||||
/// Write one glyph outline as PDF path operators, refusing any coordinate or derived Bézier
|
||||
/// control that is not finite. A quadratic's cubic controls come from finite differences, which
|
||||
/// can overflow even when the endpoints are finite.
|
||||
fn write_path_elements(out: &mut String, elements: &[photocraft_text::render::PathEl]) -> Option<()> {
|
||||
let mut cur = [0.0f64, 0.0f64];
|
||||
for el in elements {
|
||||
match *el {
|
||||
photocraft_text::render::PathEl::MoveTo([x, y]) => {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
cur = [x, y];
|
||||
writeln!(out, "{x} {y} m").ok()?;
|
||||
}
|
||||
photocraft_text::render::PathEl::LineTo([x, y]) => {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
cur = [x, y];
|
||||
writeln!(out, "{x} {y} l").ok()?;
|
||||
}
|
||||
photocraft_text::render::PathEl::QuadTo([cx, cy], [x, y]) => {
|
||||
if !cx.is_finite() || !cy.is_finite() || !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let c1x = cur[0] + 2.0 / 3.0 * (cx - cur[0]);
|
||||
let c1y = cur[1] + 2.0 / 3.0 * (cy - cur[1]);
|
||||
let c2x = x + 2.0 / 3.0 * (cx - x);
|
||||
let c2y = y + 2.0 / 3.0 * (cy - y);
|
||||
if !c1x.is_finite() || !c1y.is_finite() || !c2x.is_finite() || !c2y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
cur = [x, y];
|
||||
writeln!(out, "{c1x} {c1y} {c2x} {c2y} {x} {y} c").ok()?;
|
||||
}
|
||||
photocraft_text::render::PathEl::CurveTo([c1x, c1y], [c2x, c2y], [x, y]) => {
|
||||
if !c1x.is_finite() || !c1y.is_finite() || !c2x.is_finite() || !c2y.is_finite() || !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
cur = [x, y];
|
||||
writeln!(out, "{c1x} {c1y} {c2x} {c2y} {x} {y} c").ok()?;
|
||||
}
|
||||
photocraft_text::render::PathEl::Close => {
|
||||
writeln!(out, "h").ok()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn layout_ops(
|
||||
layer: &TextLayer,
|
||||
mode: ColorMode,
|
||||
channels: usize,
|
||||
layout: &photocraft_text::TextLayout,
|
||||
selection_font: &super::selection::SelectionFont,
|
||||
) -> Option<String> {
|
||||
if !layout_checks(layer, mode, layout) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let outlines = photocraft_text::render::glyph_outlines(layout, &layer.transform, None);
|
||||
|
||||
let mut out = String::new();
|
||||
|
||||
let mut current_color: Option<photocraft_color::Color> = None;
|
||||
|
||||
for glyph in &outlines {
|
||||
let style = layout.styles.get(glyph.style as usize)?;
|
||||
let color = style.color.in_mode(mode);
|
||||
if current_color.as_ref() != Some(&color) {
|
||||
write_fill_color(&mut out, &color, mode, channels)?;
|
||||
current_color = Some(color);
|
||||
}
|
||||
write_path_elements(&mut out, &glyph.elements)?;
|
||||
writeln!(out, "f*").ok()?;
|
||||
}
|
||||
|
||||
for d in &layout.decorations {
|
||||
let style = layout.styles.get(d.style as usize)?;
|
||||
let color = style.color.in_mode(mode);
|
||||
if current_color.as_ref() != Some(&color) {
|
||||
write_fill_color(&mut out, &color, mode, channels)?;
|
||||
current_color = Some(color);
|
||||
}
|
||||
let p0 = layer.transform.apply(photocraft_geom::Point::new(f64::from(d.x0), f64::from(d.y0)));
|
||||
let p1 = layer.transform.apply(photocraft_geom::Point::new(f64::from(d.x1), f64::from(d.y0)));
|
||||
let p2 = layer.transform.apply(photocraft_geom::Point::new(f64::from(d.x1), f64::from(d.y1)));
|
||||
let p3 = layer.transform.apply(photocraft_geom::Point::new(f64::from(d.x0), f64::from(d.y1)));
|
||||
if !p0.x.is_finite()
|
||||
|| !p0.y.is_finite()
|
||||
|| !p1.x.is_finite()
|
||||
|| !p1.y.is_finite()
|
||||
|| !p2.x.is_finite()
|
||||
|| !p2.y.is_finite()
|
||||
|| !p3.x.is_finite()
|
||||
|| !p3.y.is_finite()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
writeln!(out, "{} {} m", p0.x, p0.y).ok()?;
|
||||
writeln!(out, "{} {} l", p1.x, p1.y).ok()?;
|
||||
writeln!(out, "{} {} l", p2.x, p2.y).ok()?;
|
||||
writeln!(out, "{} {} l", p3.x, p3.y).ok()?;
|
||||
writeln!(out, "h").ok()?;
|
||||
writeln!(out, "f").ok()?;
|
||||
}
|
||||
|
||||
selection_ops(&mut out, layer, layout, selection_font)?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Emit the invisible selection text for each shaped cluster. A span reaches the next shaped
|
||||
/// cluster, so paragraph breaks and other whitespace omitted by shaping survive
|
||||
/// in the selection; the first span also takes leading characters. Without this a multi-line
|
||||
/// layer extracts as one run of text.
|
||||
fn selection_ops(out: &mut String, layer: &TextLayer, layout: &photocraft_text::TextLayout, selection_font: &super::selection::SelectionFont) -> Option<()> {
|
||||
let [a, b, c, d, _, _] = layer.transform.m;
|
||||
let mut current_line = None;
|
||||
let mut clusters: Vec<_> = layout.clusters.iter().collect();
|
||||
clusters.sort_by_key(|cluster| cluster.range.start);
|
||||
let mut first = true;
|
||||
for (i, cluster) in clusters.iter().enumerate() {
|
||||
let text = layer.text.get(cluster.range.clone())?;
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let span_start = if first { 0 } else { cluster.range.start };
|
||||
first = false;
|
||||
let next_start = clusters.iter().skip(i + 1).find(|next| !next.range.is_empty()).map_or(layer.text.len(), |next| next.range.start);
|
||||
let span_end = next_start.max(cluster.range.end).min(layer.text.len());
|
||||
// Only layout whitespace may extend a cluster. Truncated box text must stay raster,
|
||||
// rather than exposing unpainted source text in the selection plane.
|
||||
if !layer.text.get(span_start..cluster.range.start)?.chars().all(char::is_whitespace)
|
||||
|| !layer.text.get(cluster.range.end..span_end)?.chars().all(char::is_whitespace)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let span = layer.text.get(span_start..span_end)?;
|
||||
let line = layout.lines.get(cluster.line)?;
|
||||
if current_line != Some(cluster.line) {
|
||||
if current_line.is_some() {
|
||||
writeln!(out, "ET").ok()?;
|
||||
}
|
||||
writeln!(out, "BT\n3 Tr\n/FSelect 1 Tf").ok()?;
|
||||
current_line = Some(cluster.line);
|
||||
}
|
||||
let height = (line.ascent + line.descent).max(1.0);
|
||||
let width = cluster.advance.abs() / text.chars().count().max(1) as f32;
|
||||
if !height.is_finite() || !width.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let x = cluster.x;
|
||||
let origin = layer.transform.apply(photocraft_geom::Point::new(f64::from(x), f64::from(line.baseline)));
|
||||
if !origin.x.is_finite() || !origin.y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let [ta, tb, tc, td] = [a * f64::from(width), b * f64::from(width), -c * f64::from(height), -d * f64::from(height)];
|
||||
if !ta.is_finite() || !tb.is_finite() || !tc.is_finite() || !td.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let hex = actual_text_hex(span);
|
||||
let encoded = selection_font.encode(span)?;
|
||||
writeln!(out, "/Span << /ActualText <{hex}> >> BDC").ok()?;
|
||||
writeln!(out, "{ta} {tb} {tc} {td} {} {} Tm", origin.x, origin.y).ok()?;
|
||||
writeln!(out, "<{encoded}> Tj\nEMC").ok()?;
|
||||
}
|
||||
|
||||
if current_line.is_some() {
|
||||
writeln!(out, "ET").ok()?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_geom::Affine;
|
||||
|
||||
#[test]
|
||||
fn embedding_restrictions_refuse_outlines() {
|
||||
let original = photocraft_text::fonts::INTER_REGULAR.as_slice();
|
||||
assert!(outlines_permitted(original, 0));
|
||||
let count = u16::from_be_bytes(original[4..6].try_into().unwrap()) as usize;
|
||||
let record = (0..count).map(|i| 12 + 16 * i).find(|at| &original[*at..*at + 4] == b"OS/2").unwrap();
|
||||
let offset = u32::from_be_bytes(original[record + 8..record + 12].try_into().unwrap()) as usize;
|
||||
for flags in [0x0002u16, 0x0100, 0x0200] {
|
||||
let mut restricted = original.to_vec();
|
||||
restricted[offset + 8..offset + 10].copy_from_slice(&flags.to_be_bytes());
|
||||
assert!(!outlines_permitted(&restricted, 0), "fsType {flags:#x}");
|
||||
let layer = TextLayer { text: "Restricted".into(), ..Default::default() };
|
||||
let mut layout = photocraft_text::TextEngine::new().layout(&layer, 72.0);
|
||||
for face in &mut layout.faces {
|
||||
face.font.data = restricted.clone().into();
|
||||
face.font.index = 0;
|
||||
}
|
||||
let font = super::super::selection::SelectionFont::new(std::iter::once(layer.text.as_str()));
|
||||
assert!(layout_ops(&layer, ColorMode::Rgb, 3, &layout, &font).is_none(), "restricted text must remain raster");
|
||||
}
|
||||
assert!(!outlines_permitted(&[], 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_layer_emits_actualtext_outlines_and_invisible_text() {
|
||||
let layer = TextLayer {
|
||||
text: "Hello".into(),
|
||||
size_pt: 12.0,
|
||||
color: photocraft_color::Color::rgb(0.0, 0.5, 1.0),
|
||||
transform: Affine::translate(10.0, 20.0),
|
||||
..Default::default()
|
||||
};
|
||||
let ops = text_ops(&layer, ColorMode::Rgb, 72.0).unwrap();
|
||||
assert!(ops.contains("/Span << /ActualText <FEFF0048> >> BDC"));
|
||||
assert!(ops.contains("0 0.5 1 rg"));
|
||||
assert!(ops.contains(" m\n"));
|
||||
assert!(ops.contains(" c\n"));
|
||||
assert!(ops.contains("h\nf*\n"));
|
||||
assert!(ops.contains("BT\n3 Tr\n"));
|
||||
assert!(ops.contains("/FSelect"));
|
||||
assert!(ops.contains("> Tj\nEMC\n"));
|
||||
assert!(ops.ends_with("ET\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_warp_and_invalid_alpha() {
|
||||
let warped = TextLayer { text: "Warped".into(), warp: Some(photocraft_doc::text::TextWarp::default()), ..Default::default() };
|
||||
assert!(text_ops(&warped, ColorMode::Rgb, 72.0).is_none());
|
||||
|
||||
let transparent = TextLayer {
|
||||
text: "Alpha".into(),
|
||||
color: photocraft_color::Color { mode: ColorMode::Rgb, c: [1.0, 0.0, 0.0, 0.0], alpha: 0.5 },
|
||||
..Default::default()
|
||||
};
|
||||
assert!(text_ops(&transparent, ColorMode::Rgb, 72.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_path_text_and_synthetic_bold() {
|
||||
let on_path = TextLayer {
|
||||
text: "Path".into(),
|
||||
shape: photocraft_doc::text::TextShape::Path { path: Default::default(), start: 0.0, flip: false },
|
||||
..Default::default()
|
||||
};
|
||||
assert!(text_ops(&on_path, ColorMode::Rgb, 72.0).is_none());
|
||||
|
||||
let faux_bold = TextLayer {
|
||||
text: "Bold".into(),
|
||||
runs: vec![photocraft_doc::text::TextRun { len: 4, style: photocraft_doc::text::CharStyle { faux_bold: true, ..Default::default() } }],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(text_ops(&faux_bold, ColorMode::Rgb, 72.0).is_none());
|
||||
|
||||
// A face without the requested weight is emboldened by font matching.
|
||||
let plain = TextLayer { text: "Bold".into(), ..Default::default() };
|
||||
let mut layout = photocraft_text::TextEngine::new().layout(&plain, 72.0);
|
||||
assert!(!synthetic_bold(&layout));
|
||||
for face in &mut layout.faces {
|
||||
face.embolden = true;
|
||||
}
|
||||
assert!(synthetic_bold(&layout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decorations_are_emitted_as_rectangles() {
|
||||
let layer = TextLayer {
|
||||
text: "Underline".into(),
|
||||
runs: vec![photocraft_doc::text::TextRun {
|
||||
len: 9,
|
||||
style: photocraft_doc::text::CharStyle { underline: true, color: photocraft_color::Color::rgb(1.0, 0.0, 0.0), ..Default::default() },
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let ops = text_ops(&layer, ColorMode::Rgb, 72.0).unwrap();
|
||||
assert!(ops.contains("h\nf\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quadratic_cubic_controls_that_overflow_are_refused() {
|
||||
use photocraft_text::render::PathEl;
|
||||
// Finite endpoints whose difference is not finite: 1e308 - -1e308 overflows.
|
||||
let mut out = String::new();
|
||||
let overflowing = [PathEl::MoveTo([1e308, 0.0]), PathEl::QuadTo([-1e308, 0.0], [1e308, 0.0])];
|
||||
assert!(write_path_elements(&mut out, &overflowing).is_none(), "a non-finite derived control must be refused");
|
||||
assert!(!out.contains(" c\n"), "no cubic with a non-finite control is written:\n{out}");
|
||||
|
||||
// A representable quadratic still becomes the equivalent cubic.
|
||||
let mut out = String::new();
|
||||
let normal = [PathEl::MoveTo([0.0, 0.0]), PathEl::QuadTo([30.0, 60.0], [60.0, 0.0])];
|
||||
assert!(write_path_elements(&mut out, &normal).is_some());
|
||||
assert!(out.contains("20 40 40 40 60 0 c\n"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_text_matrix_products_are_refused() {
|
||||
// Whitespace shapes no outlines, so the text matrix is the only place the transform
|
||||
// reaches the output. Zero the positions: the products overflow, while the cluster
|
||||
// origin they are added to stays finite.
|
||||
let layer =
|
||||
TextLayer { text: " ".into(), size_pt: 100.0, transform: photocraft_geom::Affine { m: [1e308, 1e308, 0.0, 0.0, 0.0, 0.0] }, ..Default::default() };
|
||||
let mut layout = photocraft_text::TextEngine::new().layout(&layer, 72.0);
|
||||
for cluster in &mut layout.clusters {
|
||||
cluster.x = 0.0;
|
||||
cluster.advance = cluster.advance.abs().max(2.0);
|
||||
}
|
||||
for line in &mut layout.lines {
|
||||
line.baseline = 0.0;
|
||||
}
|
||||
let cluster = layout.clusters.first().expect("a whitespace cluster");
|
||||
let line = layout.lines.get(cluster.line).expect("a line");
|
||||
let origin = layer.transform.apply(photocraft_geom::Point::new(f64::from(cluster.x), f64::from(line.baseline)));
|
||||
assert!(origin.x.is_finite() && origin.y.is_finite(), "the fixture's origin stays finite");
|
||||
let font = super::super::selection::SelectionFont::new(std::iter::once(layer.text.as_str()));
|
||||
assert!(layout_ops(&layer, ColorMode::Rgb, 3, &layout, &font).is_none(), "overflowing Tm coefficients must not be emitted");
|
||||
assert!(text_ops(&layer, ColorMode::Rgb, 72.0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,8 @@ pub struct ClusterInfo {
|
||||
pub struct TextLayout {
|
||||
pub faces: Vec<GlyphFace>,
|
||||
pub glyphs: Vec<PlacedGlyph>,
|
||||
/// Layer-text byte range represented by each glyph, in the same order as `glyphs`.
|
||||
pub glyph_ranges: Vec<Range<usize>>,
|
||||
pub decorations: Vec<DecorationRect>,
|
||||
pub lines: Vec<LineInfo>,
|
||||
pub clusters: Vec<ClusterInfo>,
|
||||
|
||||
@@ -20,6 +20,8 @@ struct KernSlot {
|
||||
run: usize,
|
||||
/// Byte offset in the paragraph text.
|
||||
start: usize,
|
||||
/// Paragraph-text byte range represented by this visual cluster.
|
||||
text_range: std::ops::Range<usize>,
|
||||
first: Option<u32>,
|
||||
last: Option<u32>,
|
||||
font: parley::FontData,
|
||||
@@ -53,6 +55,8 @@ struct KernCursor {
|
||||
run: usize,
|
||||
/// Slot of the cluster each glyph of the run belongs to.
|
||||
glyph_slot: Vec<usize>,
|
||||
/// Layer-text byte range represented by each glyph, parallel to `glyph_slot`.
|
||||
glyph_range: Vec<std::ops::Range<usize>>,
|
||||
/// Glyphs emitted so far.
|
||||
emitted: usize,
|
||||
/// First slot whose kerning isn't applied yet.
|
||||
@@ -136,6 +140,7 @@ fn collect_kern_slots(line: &Line<'_>, para: &Para<'_>) -> Vec<KernSlot> {
|
||||
slots.push(KernSlot {
|
||||
run: run.index(),
|
||||
start: c.text_range().start,
|
||||
text_range: c.text_range(),
|
||||
first,
|
||||
last,
|
||||
font: run.font().clone(),
|
||||
@@ -193,18 +198,24 @@ fn kern_cursors(line: &Line<'_>, slots: &[KernSlot]) -> Vec<KernCursor> {
|
||||
continue;
|
||||
}
|
||||
let base = slots.iter().position(|s| s.run == run.index()).unwrap_or(0);
|
||||
cursors.push(KernCursor { run: run.index(), glyph_slot: glyph_slots(run, base), emitted: 0, applied: base });
|
||||
let (glyph_slot, glyph_range) = glyph_mapping(run, base, slots);
|
||||
cursors.push(KernCursor { run: run.index(), glyph_slot, glyph_range, emitted: 0, applied: base });
|
||||
}
|
||||
cursors
|
||||
}
|
||||
|
||||
/// For each glyph of `run`, the slot of its cluster (the run's clusters start at slot `base`).
|
||||
fn glyph_slots(run: &Run<'_, RunBrush>, base: usize) -> Vec<usize> {
|
||||
fn glyph_mapping(run: &Run<'_, RunBrush>, base: usize, slots: &[KernSlot]) -> (Vec<usize>, Vec<std::ops::Range<usize>>) {
|
||||
let mut g2s = Vec::new();
|
||||
let mut ranges = Vec::new();
|
||||
for (ci, c) in run.visual_clusters().enumerate() {
|
||||
g2s.extend(c.glyphs().map(|_| base + ci));
|
||||
let range = slots.get(base + ci).map(|s| s.text_range.clone()).unwrap_or_else(|| c.text_range());
|
||||
for _ in c.glyphs() {
|
||||
g2s.push(base + ci);
|
||||
ranges.push(range.clone());
|
||||
}
|
||||
}
|
||||
g2s
|
||||
(g2s, ranges)
|
||||
}
|
||||
|
||||
/// Distributes `slack` between the clusters of the line from `c0` (and their glyphs from `g0`);
|
||||
@@ -495,6 +506,7 @@ impl Para<'_> {
|
||||
let mut pen = gr.offset();
|
||||
let mut cursor = state.cursors.iter_mut().find(|c| c.run == run.index());
|
||||
for g in gr.glyphs() {
|
||||
let source_range = cursor.as_deref().and_then(|c| c.glyph_range.get(c.emitted)).cloned().unwrap_or_default();
|
||||
if let Some(c) = cursor.as_deref_mut() {
|
||||
c.before_glyph(lc.kern_px, &mut state.extra);
|
||||
}
|
||||
@@ -506,6 +518,7 @@ impl Para<'_> {
|
||||
style: gr.style().brush.0,
|
||||
orient: GlyphOrient::Horizontal,
|
||||
});
|
||||
out.glyph_ranges.push(self.map_offset(source_range.start)..self.map_offset(source_range.end));
|
||||
if let Some(v) = &vm {
|
||||
let upright = class == 1 || (class == 2 && !plain.contains(&g.id));
|
||||
state.vinfo.push(VGlyph {
|
||||
|
||||
@@ -548,14 +548,18 @@ impl skrifa::outline::OutlinePen for Recorder<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Glyph outlines of `layout` in document space (through `transform`, bent by `warp`), one
|
||||
/// element list per glyph, for Type › Create Work Path and Convert to Shape. Faux bold is not
|
||||
/// applied (Photoshop also converts the regular outline); faux italic and scaling are. Under a
|
||||
/// warp, control points are mapped directly, which is exact for straight segments and a close
|
||||
/// approximation for curves.
|
||||
pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> Vec<Vec<PathEl>> {
|
||||
/// Positioned glyph outline with its style and character run information.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GlyphOutline {
|
||||
pub glyph_index: usize,
|
||||
pub style: u32,
|
||||
pub elements: Vec<PathEl>,
|
||||
}
|
||||
|
||||
/// Glyph outlines of `layout` preserving the style index of each glyph.
|
||||
pub fn glyph_outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> Vec<GlyphOutline> {
|
||||
let mut glyphs = Vec::new();
|
||||
for g in &layout.glyphs {
|
||||
for (i, g) in layout.glyphs.iter().enumerate() {
|
||||
let face = &layout.faces[g.face as usize];
|
||||
let Ok(font) = skrifa::FontRef::from_index(face.font.data.as_ref(), face.font.index) else {
|
||||
continue;
|
||||
@@ -579,12 +583,21 @@ pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) ->
|
||||
let mut rec = Recorder { glyph, post: Xform(transform.m), warp: bend, out: Vec::new() };
|
||||
let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords));
|
||||
if outline.draw(settings, &mut rec).is_ok() && !rec.out.is_empty() {
|
||||
glyphs.push(rec.out);
|
||||
glyphs.push(GlyphOutline { glyph_index: i, style: g.style, elements: rec.out });
|
||||
}
|
||||
}
|
||||
glyphs
|
||||
}
|
||||
|
||||
/// Glyph outlines of `layout` in document space (through `transform`, bent by `warp`), one
|
||||
/// element list per glyph, for Type › Create Work Path and Convert to Shape. Faux bold is not
|
||||
/// applied (the regular outline is converted); faux italic and scaling are. Under a
|
||||
/// warp, control points are mapped directly, which is exact for straight segments and a close
|
||||
/// approximation for curves.
|
||||
pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> Vec<Vec<PathEl>> {
|
||||
glyph_outlines(layout, transform, warp).into_iter().map(|g| g.elements).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MAX_PIXELS, fits_raster_budget, rect_from_bounds, zeroed};
|
||||
|
||||
@@ -13,6 +13,22 @@ fn styled(text: &str, style: CharStyle) -> TextLayer {
|
||||
TextLayer { text: text.into(), runs: vec![TextRun { len: text.len(), style }], ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_source_ranges_follow_shaped_clusters() {
|
||||
let mut engine = TextEngine::new();
|
||||
for text in ["office", "Aé B", "First\nSecond"] {
|
||||
let layer = point(text, 24.0);
|
||||
let layout = engine.layout(&layer, 72.0);
|
||||
assert!(!layout.glyphs.is_empty());
|
||||
assert_eq!(layout.glyphs.len(), layout.glyph_ranges.len());
|
||||
for range in &layout.glyph_ranges {
|
||||
assert!(range.start < range.end, "empty range in {text:?}");
|
||||
assert!(text.get(range.clone()).is_some(), "invalid range {range:?} in {text:?}");
|
||||
assert!(layout.clusters.iter().any(|cluster| cluster.range == *range), "missing cluster for {range:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rasteriser's pixel stages — composing one colour's coverage, unpremultiplying it, and
|
||||
/// converting the tiles — are per pixel: no pixel reads a slot another pixel wrote. Spreading them
|
||||
/// over threads therefore has to leave every byte as it was, which a one-thread pool checks.
|
||||
@@ -3614,6 +3630,25 @@ fn restyling_the_photoshop_era_objects_keeps_the_raw_strings_of_the_file() {
|
||||
assert_eq!(runs.first().and_then(|(_, f)| f.get("FontSize")).and_then(E::as_f64), Some(24.0), "and the legacy copy was restyled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_outlines_preserves_style_indices() {
|
||||
let mut e = TextEngine::new();
|
||||
let t = TextLayer {
|
||||
text: "AB".into(),
|
||||
transform: Affine::translate(10.0, 30.0),
|
||||
runs: vec![
|
||||
photocraft_doc::text::TextRun { len: 1, style: CharStyle { color: Color::rgb(1.0, 0.0, 0.0), ..Default::default() } },
|
||||
photocraft_doc::text::TextRun { len: 1, style: CharStyle { color: Color::rgb(0.0, 1.0, 0.0), ..Default::default() } },
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let layout = e.layout(&t, 72.0);
|
||||
let glyphs = crate::render::glyph_outlines(&layout, &t.transform, None);
|
||||
assert!(glyphs.len() >= 2);
|
||||
assert_eq!(glyphs[0].style, 0);
|
||||
assert_eq!(glyphs[1].style, 1);
|
||||
}
|
||||
|
||||
/// Colour passes are grouped in one walk of the glyphs: with a colour per letter, every placed
|
||||
/// glyph lands in exactly one pass and each pass holds only its own glyphs.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# PDF vector export
|
||||
|
||||
Updated 2026-11-03 for PR #10355 review.
|
||||
|
||||
The visible text is glyph outlines, not embedded original font programs. The selection plane uses an embedded empty Type 3 font with a ToUnicode map (including UTF-16 surrogate pairs); its character procedures draw nothing. Each shaped cluster owns a selection span, and that span reaches the next shaped cluster, so paragraph breaks and other whitespace omitted by shaping are preserved; layers with omitted non-whitespace source text stay rasterized; selection of one cluster still does not replace it with the whole layer and a ligature remains a single cluster. Source font OS/2 permissions must allow outline embedding and subsetting; restricted, bitmap-only, no-subsetting, malformed or missing permissions keep text in the raster backdrop.
|
||||
|
||||
The page retains its raster image beneath supported topmost vectors. Effects (including drop shadows), non-Normal blending, reduced opacity/fill, masks, clipping and advanced blending remain in that composite. Embedded ICC profiles also keep the whole document rasterized; the image retains its ICCBased colour space. This change does not emit colour-managed vector operators. A layer whose text matrix products overflow is treated as unsupported rather than emitting non-finite operators.
|
||||
|
||||
The selection font is built only from the visible top layers that are actually exported as vectors: hidden layers and text below an unsupported or blocking layer contribute no codes. The font supports at most 255 distinct characters; a text layer that would exceed that stops vector export there, and it and everything below it stay in the raster composite instead of partially hiding their sources. Original font embedding, larger repertoires, PDF import and vector layers below an unsupported visible layer remain open.
|
||||
|
||||
Regression coverage includes extraction by lopdf from a PDF containing Unicode text over a synthetic photo, paragraph breaks surviving extraction, a hidden or blocked layer with more than 255 distinct characters not disabling top-layer vectors, and raster fallback for font permission flags, shadows, blending, opacity and ICC profiles. Font restriction fixtures mutate the bundled font in memory; no fonts are added to the repository.
|
||||
|
||||
The `measure_features_impl` hunk noted in the review belongs to a separate measurement optimization already included in this PR: geometry-only measurements skip the grayscale composite. It is unrelated to PDF export and is already documented under “Also in this PR” in the PR description.
|
||||
Reference in new issue
Block a user