mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-11 16:32:22 -06:00
* Depend on craft-pdf (krilla writer, reader for tests) at craft-libs 9f75d3dc The root patches pin the patched krilla and hayro-syntax/hayro-jbig2 to the same revision, as craft-pdf requires. * Plot PDFs with craft-pdf's krilla writer The hand-written object, xref, stream and font writer goes. Each sheet's display list is built, written as its page and dropped before the next. Strokes with the same colour, width and transparency stay one path, and each filled primitive is one path of its triangles. Text in embeddable TrueType fonts is drawn as glyphs; krilla subsets the fonts and writes their ToUnicode maps, so the hand-written subsetter goes too and cadcraft-fonts keeps only the fsType embedding check. Primitives with a point that is not finite or beyond 1e7 pt are left out. A font krilla cannot embed after all makes the export retry with text as outlines. Optional content (PDF layers) is not written until craft-libs' writer supports it: the layer transitions go through one empty function, layers that are off are not plotted, and the layer tests are ignored. The tests read the PDF back with craft-pdf's reader. * Pin craft-pdf and its krilla and hayro patches to craft-libs cd160e73 cd160e73 adds optional content groups to the patched krilla. * Write drawing layers as PDF optional content groups Each drawing layer a page draws on gets one group, named after the layer and shared by all pages, created before the first page that uses it. A group starts hidden when its layer is off in the drawing, so `offLayers` again plots layers that are off on hidden PDF layers. At most 4096 groups per file; objects on further layers print unmarked. A single sheet with groups opens with the viewer's layer panel (/PageMode /UseOC). Layer changes end and start the marked content, and the last mark ends before the page finishes. The layer and text tests that check the groups run again, and a test covers the group cap. * Plot a zero-length line as a round dot in PDFs krilla leaves out a path without extent, so a lone zero-length line drew nothing. It is filled as the dot its round-capped stroke would make, as wide as the stroke, with 0.25 pt for a hairline. * PDF: retry as outlines when the writer fails inside a font; cap layer names; restore two test checks * Tests: read plotted PDFs back through the shared reader in plot_layout_scale and plot_styles * Pin craft-pdf, krilla, hayro-syntax and hayro-jbig2 to craft-libs main (7895f5e) * PDF tests: read path coordinates in page space; cover the font retry and out-of-range points - pdf_read maps path operands through the CTM (`page_ops`, used by `drawn` and `clip_rect`) and checks that the plot area is drawn in page space. - The 1:1 layout test finds the viewport corner at (72, 72) in page space, so a mirrored page fails it. - A font with a short hmtx passes the writer's font checks but fails subsetting; the plot retries and draws its text as outlines. - A primitive with a point past 1e7 pt is left out, and the rest still plots. * PDF: clip a primitive with a far vertex to the plot area instead of leaving it out A polyline or fill with a point past the writer's f32 range now keeps the parts inside the plot area (polylines with the area widened by the stroke width), each piece its own subpath. Primitives in range are written as before. The test reader requires a decodable content stream.
519 lines
20 KiB
Rust
519 lines
20 KiB
Rust
//! Reads a plotted PDF back for tests, with craft-pdf's reader: pages, media boxes, decoded
|
||
//! content, transparency graphics states, link annotations, fonts, page labels and document
|
||
//! information.
|
||
#![allow(dead_code)]
|
||
|
||
use std::ops::Deref;
|
||
|
||
use craft_pdf::syntax::object::{Array, Dict, Name, Stream, String as PdfString};
|
||
|
||
/// What a reader sees in a PDF.
|
||
pub struct Doc {
|
||
pub pages: Vec<Page>,
|
||
/// Page labels, one per page (empty when the file has none).
|
||
pub labels: Vec<String>,
|
||
pub title: Option<String>,
|
||
pub creator: Option<String>,
|
||
pub producer: Option<String>,
|
||
/// Optional content groups (PDF layers) in `/OCProperties` order.
|
||
pub ocgs: Vec<Ocg>,
|
||
/// The catalog's `/PageMode`.
|
||
pub page_mode: Option<String>,
|
||
}
|
||
|
||
pub struct Ocg {
|
||
pub name: String,
|
||
/// Listed in the default configuration's `/OFF` array (starts hidden).
|
||
pub off: bool,
|
||
}
|
||
|
||
pub struct Page {
|
||
/// `[x0 y0 x1 y1]` in points.
|
||
pub media: [f64; 4],
|
||
/// The decoded content stream(s), as text (binary string bytes replaced).
|
||
pub content: String,
|
||
/// The decoded content stream(s) as bytes.
|
||
pub content_bytes: Vec<u8>,
|
||
/// Whether a content stream is compressed (has a filter).
|
||
pub compressed: bool,
|
||
/// Stroke and fill alpha (`/CA`, `/ca`) of each graphics state in the page's resources.
|
||
pub alphas: Vec<(f64, f64)>,
|
||
pub links: Vec<Link>,
|
||
pub fonts: Vec<Font>,
|
||
/// The page's marked-content properties: resource name and the name of the optional
|
||
/// content group it refers to.
|
||
pub properties: Vec<(String, String)>,
|
||
}
|
||
|
||
pub struct Link {
|
||
/// `[x0 y0 x1 y1]` in points.
|
||
pub rect: [f64; 4],
|
||
pub uri: String,
|
||
}
|
||
|
||
pub struct Font {
|
||
pub subtype: String,
|
||
pub base_font: String,
|
||
pub encoding: Option<String>,
|
||
/// The descendant font's subtype (Type0 fonts).
|
||
pub descendant: Option<String>,
|
||
/// The descendant font's `/CIDToGIDMap` name.
|
||
pub cid_to_gid: Option<String>,
|
||
/// Glyph widths (`/W` of the descendant font): code and width.
|
||
pub widths: Vec<(u32, f64)>,
|
||
/// The decoded `ToUnicode` CMap.
|
||
pub to_unicode: Option<String>,
|
||
/// The embedded TrueType file (`FontFile2`).
|
||
pub font_file: Option<Vec<u8>>,
|
||
}
|
||
|
||
/// A PDF text string: UTF-16BE with a byte order mark, else one byte per character.
|
||
pub fn text_string(b: &[u8]) -> String {
|
||
match b.strip_prefix(&[0xFE, 0xFF]) {
|
||
Some(u) => String::from_utf16_lossy(&u.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect::<Vec<_>>()),
|
||
None => b.iter().map(|c| char::from(*c)).collect(),
|
||
}
|
||
}
|
||
|
||
fn name(d: &Dict<'_>, key: &[u8]) -> Option<String> {
|
||
d.get::<Name<'_>>(key).map(|n| n.as_str().to_string())
|
||
}
|
||
|
||
fn rect(a: &Array<'_>) -> [f64; 4] {
|
||
let v: Vec<f64> = a.iter::<f64>().collect();
|
||
assert_eq!(v.len(), 4, "a rectangle: {v:?}");
|
||
[v[0], v[1], v[2], v[3]]
|
||
}
|
||
|
||
fn decoded(s: &Stream<'_>) -> Vec<u8> {
|
||
s.decoded().expect("a stream that decodes").to_vec()
|
||
}
|
||
|
||
fn font(d: &Dict<'_>) -> Font {
|
||
let descendant = d.get::<Array<'_>>(b"DescendantFonts").and_then(|a| a.iter::<Dict<'_>>().next());
|
||
let font_file =
|
||
descendant.as_ref().unwrap_or(d).get::<Dict<'_>>(b"FontDescriptor").and_then(|fd| fd.get::<Stream<'_>>(b"FontFile2")).map(|s| decoded(&s));
|
||
let mut widths = Vec::new();
|
||
if let Some(w) = descendant.as_ref().and_then(|c| c.get::<Array<'_>>(b"W")) {
|
||
let mut it = w.flex_iter();
|
||
while let Some(first) = it.next::<u32>() {
|
||
if let Some(list) = it.next::<Array<'_>>() {
|
||
widths.extend(list.iter::<f64>().enumerate().map(|(i, v)| (first + i as u32, v)));
|
||
} else {
|
||
let (last, v) = (it.next::<u32>().expect("a /W range end"), it.next::<f64>().expect("a /W range width"));
|
||
widths.extend((first..=last).map(|c| (c, v)));
|
||
}
|
||
}
|
||
}
|
||
Font {
|
||
encoding: name(d, b"Encoding"),
|
||
cid_to_gid: descendant.as_ref().and_then(|c| name(c, b"CIDToGIDMap")),
|
||
widths,
|
||
subtype: name(d, b"Subtype").unwrap_or_default(),
|
||
base_font: name(d, b"BaseFont").unwrap_or_default(),
|
||
descendant: descendant.as_ref().and_then(|c| name(c, b"Subtype")),
|
||
to_unicode: d.get::<Stream<'_>>(b"ToUnicode").map(|s| String::from_utf8_lossy(&decoded(&s)).into_owned()),
|
||
font_file,
|
||
}
|
||
}
|
||
|
||
/// Read a PDF; panics when it doesn't parse.
|
||
pub fn read(bytes: &[u8]) -> Doc {
|
||
assert!(bytes.starts_with(b"%PDF-"), "a PDF header");
|
||
let pdf = craft_pdf::open_slice(bytes, None).expect("the PDF opens");
|
||
let mut pages = Vec::new();
|
||
for page in pdf.pages().iter() {
|
||
let m = page.media_box();
|
||
let raw = page.raw();
|
||
let resources = raw.get::<Dict<'_>>(b"Resources");
|
||
let dicts = |key: &[u8]| -> Vec<Dict<'_>> {
|
||
let Some(d) = resources.as_ref().and_then(|r| r.get::<Dict<'_>>(key)) else { return Vec::new() };
|
||
let keys: Vec<Vec<u8>> = d.keys().map(|k| k.deref().to_vec()).collect();
|
||
keys.iter().filter_map(|k| d.get::<Dict<'_>>(k.as_slice())).collect()
|
||
};
|
||
let alphas = dicts(b"ExtGState").iter().map(|g| (g.get::<f64>(b"CA").unwrap_or(1.0), g.get::<f64>(b"ca").unwrap_or(1.0))).collect();
|
||
let fonts = dicts(b"Font").iter().map(font).collect();
|
||
let properties = match resources.as_ref().and_then(|r| r.get::<Dict<'_>>(b"Properties")) {
|
||
Some(d) => d
|
||
.keys()
|
||
.filter_map(|k| {
|
||
let ocg = d.get::<Dict<'_>>(k.deref())?;
|
||
Some((k.as_str().to_string(), ocg.get::<PdfString<'_>>(b"Name").map(|n| text_string(n.as_bytes())).unwrap_or_default()))
|
||
})
|
||
.collect(),
|
||
None => Vec::new(),
|
||
};
|
||
let links = raw
|
||
.get::<Array<'_>>(b"Annots")
|
||
.map(|a| a.iter::<Dict<'_>>().collect::<Vec<_>>())
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.filter(|a| name(a, b"Subtype").as_deref() == Some("Link"))
|
||
.map(|a| Link {
|
||
rect: rect(&a.get::<Array<'_>>(b"Rect").expect("a link rectangle")),
|
||
uri: a
|
||
.get::<Dict<'_>>(b"A")
|
||
.and_then(|act| act.get::<PdfString<'_>>(b"URI"))
|
||
.map(|s| String::from_utf8_lossy(s.as_bytes()).into_owned())
|
||
.unwrap_or_default(),
|
||
})
|
||
.collect();
|
||
let bytes = page.page_stream().expect("a decodable page content stream");
|
||
// The writer flips its y-down surface back, so the plot area is drawn in PDF points.
|
||
let content = String::from_utf8_lossy(bytes).into_owned();
|
||
if let Some(ctm) = clip_ctm(&content) {
|
||
assert!(ctm.iter().zip(IDENTITY).all(|(a, b)| (a - b).abs() < 1e-6), "the plot area is drawn under {ctm:?}, not in page space");
|
||
}
|
||
pages.push(Page {
|
||
media: [m.x0, m.y0, m.x1, m.y1],
|
||
content,
|
||
content_bytes: bytes.to_vec(),
|
||
compressed: raw
|
||
.get::<Stream<'_>>(b"Contents")
|
||
.map(|c| vec![c])
|
||
.or_else(|| raw.get::<Array<'_>>(b"Contents").map(|a| a.iter::<Stream<'_>>().collect()))
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.any(|c| c.dict().contains_key(b"Filter")),
|
||
alphas,
|
||
links,
|
||
fonts,
|
||
properties,
|
||
});
|
||
}
|
||
let catalog = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()).expect("a catalog");
|
||
let mut starts: Vec<(usize, String)> = Vec::new();
|
||
if let Some(nums) = catalog.get::<Dict<'_>>(b"PageLabels").and_then(|l| l.get::<Array<'_>>(b"Nums")) {
|
||
let mut it = nums.flex_iter();
|
||
while let Some(i) = it.next::<i64>() {
|
||
let d = it.next::<Dict<'_>>().expect("a page label dictionary");
|
||
starts.push((i as usize, d.get::<PdfString<'_>>(b"P").map(|s| text_string(s.as_bytes())).unwrap_or_default()));
|
||
}
|
||
}
|
||
let labels = if starts.is_empty() {
|
||
Vec::new()
|
||
} else {
|
||
(0..pages.len()).map(|i| starts.iter().rev().find(|(s, _)| *s <= i).map(|(_, p)| p.clone()).unwrap_or_default()).collect()
|
||
};
|
||
let mut ocgs = Vec::new();
|
||
if let Some(oc) = catalog.get::<Dict<'_>>(b"OCProperties") {
|
||
let off: Vec<_> = oc
|
||
.get::<Dict<'_>>(b"D")
|
||
.and_then(|d| d.get::<Array<'_>>(b"OFF"))
|
||
.map(|a| a.iter::<Dict<'_>>().filter_map(|g| g.obj_id()).collect())
|
||
.unwrap_or_default();
|
||
for g in oc.get::<Array<'_>>(b"OCGs").map(|a| a.iter::<Dict<'_>>().collect::<Vec<_>>()).unwrap_or_default() {
|
||
ocgs.push(Ocg {
|
||
name: g.get::<PdfString<'_>>(b"Name").map(|n| text_string(n.as_bytes())).unwrap_or_default(),
|
||
off: g.obj_id().is_some_and(|id| off.contains(&id)),
|
||
});
|
||
}
|
||
}
|
||
let page_mode = name(&catalog, b"PageMode");
|
||
let md = pdf.metadata();
|
||
let s = |v: &Option<Vec<u8>>| v.as_deref().map(text_string);
|
||
Doc { labels, title: s(&md.title), creator: s(&md.creator), producer: s(&md.producer), pages, ocgs, page_mode }
|
||
}
|
||
|
||
impl Doc {
|
||
/// The first page's content.
|
||
pub fn content(&self) -> &str {
|
||
&self.pages.first().expect("a page").content
|
||
}
|
||
pub fn media(&self) -> (f64, f64) {
|
||
let m = self.pages.first().expect("a page").media;
|
||
(m[2] - m[0], m[3] - m[1])
|
||
}
|
||
}
|
||
|
||
/// One content operator with its numeric operands.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct Op {
|
||
pub op: String,
|
||
pub nums: Vec<f64>,
|
||
/// Name operands, without the slash.
|
||
pub names: Vec<String>,
|
||
}
|
||
|
||
/// The operators of content text in order, each with its numeric operands (other operands
|
||
/// are skipped).
|
||
pub fn ops(content: &str) -> Vec<Op> {
|
||
let b: Vec<char> = content.chars().collect();
|
||
let mut out = Vec::new();
|
||
let mut nums = Vec::new();
|
||
let mut names = Vec::new();
|
||
let mut i = 0;
|
||
let delim = |c: char| c.is_whitespace() || "()<>[]{}/%".contains(c);
|
||
while i < b.len() {
|
||
let c = b[i];
|
||
if c.is_whitespace() || c == '[' || c == ']' {
|
||
i += 1;
|
||
} else if c == '%' {
|
||
while i < b.len() && b[i] != '\n' {
|
||
i += 1;
|
||
}
|
||
} else if c == '(' {
|
||
let mut depth = 0;
|
||
while i < b.len() {
|
||
match b[i] {
|
||
'\\' => i += 1,
|
||
'(' => depth += 1,
|
||
')' => {
|
||
depth -= 1;
|
||
if depth == 0 {
|
||
break;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
i += 1;
|
||
}
|
||
i += 1;
|
||
} else if c == '<' && b.get(i + 1) == Some(&'<') {
|
||
// An inline dictionary (marked-content properties): skip to its end.
|
||
let mut depth = 0;
|
||
while i < b.len() {
|
||
if b[i] == '<' && b.get(i + 1) == Some(&'<') {
|
||
depth += 1;
|
||
i += 1;
|
||
} else if b[i] == '>' && b.get(i + 1) == Some(&'>') {
|
||
depth -= 1;
|
||
i += 1;
|
||
if depth == 0 {
|
||
i += 1;
|
||
break;
|
||
}
|
||
}
|
||
i += 1;
|
||
}
|
||
} else if c == '<' {
|
||
while i < b.len() && b[i] != '>' {
|
||
i += 1;
|
||
}
|
||
i += 1;
|
||
} else if c == '/' {
|
||
i += 1;
|
||
let start = i;
|
||
while i < b.len() && !delim(b[i]) {
|
||
i += 1;
|
||
}
|
||
names.push(b[start..i].iter().collect());
|
||
} else {
|
||
let start = i;
|
||
while i < b.len() && !delim(b[i]) {
|
||
i += 1;
|
||
}
|
||
let word: String = b[start..i.max(start + 1)].iter().collect();
|
||
i = i.max(start + 1);
|
||
match word.parse::<f64>() {
|
||
Ok(v) => nums.push(v),
|
||
Err(_) => out.push(Op { op: word, nums: std::mem::take(&mut nums), names: std::mem::take(&mut names) }),
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// How many times operator `op` occurs.
|
||
pub fn count(content: &str, op: &str) -> usize {
|
||
ops(content).iter().filter(|o| o.op == op).count()
|
||
}
|
||
|
||
const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||
|
||
/// Apply matrix `m` to the point `(x, y)`.
|
||
fn apply(m: [f64; 6], x: f64, y: f64) -> (f64, f64) {
|
||
(m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
|
||
}
|
||
|
||
/// The operators of content text with path coordinates (`m`, `l`, `c`, `v`, `y`, `re`) mapped
|
||
/// through the current transformation matrix, so they are in page space. A `re` becomes the
|
||
/// rectangle between its mapped corners, which holds for the axis-aligned matrices plots use.
|
||
pub fn page_ops(content: &str) -> Vec<Op> {
|
||
let mut stack = vec![IDENTITY];
|
||
let mut out = ops(content);
|
||
for o in &mut out {
|
||
let ctm = *stack.last().expect("a graphics state");
|
||
match o.op.as_str() {
|
||
"q" => stack.push(ctm),
|
||
"Q" => {
|
||
stack.pop();
|
||
assert!(!stack.is_empty(), "unbalanced Q");
|
||
}
|
||
"cm" if o.nums.len() == 6 => {
|
||
let m = [o.nums[0], o.nums[1], o.nums[2], o.nums[3], o.nums[4], o.nums[5]];
|
||
*stack.last_mut().expect("a graphics state") = mul(m, ctm);
|
||
}
|
||
"m" | "l" | "c" | "v" | "y" => {
|
||
for [x, y] in o.nums.as_chunks_mut::<2>().0 {
|
||
(*x, *y) = apply(ctm, *x, *y);
|
||
}
|
||
}
|
||
"re" if o.nums.len() == 4 => {
|
||
let (x0, y0) = apply(ctm, o.nums[0], o.nums[1]);
|
||
let (x1, y1) = apply(ctm, o.nums[0] + o.nums[2], o.nums[1] + o.nums[3]);
|
||
o.nums = vec![x0.min(x1), y0.min(y1), (x1 - x0).abs(), (y1 - y0).abs()];
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The current transformation matrix at the first clip (`W`): what the plot area is drawn under.
|
||
pub fn clip_ctm(content: &str) -> Option<[f64; 6]> {
|
||
let mut stack = vec![IDENTITY];
|
||
for o in ops(content) {
|
||
let ctm = *stack.last().expect("a graphics state");
|
||
match o.op.as_str() {
|
||
"W" => return Some(ctm),
|
||
"q" => stack.push(ctm),
|
||
"Q" => {
|
||
stack.pop();
|
||
}
|
||
"cm" if o.nums.len() == 6 => {
|
||
let m = [o.nums[0], o.nums[1], o.nums[2], o.nums[3], o.nums[4], o.nums[5]];
|
||
*stack.last_mut().expect("a graphics state") = mul(m, ctm);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// The clip rectangle in page space: the bounds of the path closed by the first `W n`.
|
||
pub fn clip_rect(content: &str) -> Option<[f64; 4]> {
|
||
let all = page_ops(content);
|
||
let end = all.iter().position(|o| o.op == "W")?;
|
||
let start = all[..end].iter().rposition(|o| !matches!(o.op.as_str(), "m" | "l" | "h" | "re")).map_or(0, |i| i + 1);
|
||
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
|
||
for o in &all[start..end] {
|
||
let pts: Vec<(f64, f64)> = match o.op.as_str() {
|
||
"re" => vec![(o.nums[0], o.nums[1]), (o.nums[0] + o.nums[2], o.nums[1] + o.nums[3])],
|
||
"m" | "l" => vec![(o.nums[0], o.nums[1])],
|
||
_ => vec![],
|
||
};
|
||
for (x, y) in pts {
|
||
b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)];
|
||
}
|
||
}
|
||
Some(b)
|
||
}
|
||
|
||
/// The operators after the clip (the marks drawn inside the plot area), with path coordinates
|
||
/// in page space.
|
||
pub fn drawn(content: &str) -> Vec<Op> {
|
||
let all = page_ops(content);
|
||
let after = all.iter().position(|o| o.op == "W").map_or(0, |i| i + 1);
|
||
all.into_iter().skip(after).collect()
|
||
}
|
||
|
||
/// `a × b` for PDF matrices `[a b c d e f]` (apply `a`, then `b`).
|
||
pub fn mul(a: [f64; 6], b: [f64; 6]) -> [f64; 6] {
|
||
[
|
||
a[0] * b[0] + a[1] * b[2],
|
||
a[0] * b[1] + a[1] * b[3],
|
||
a[2] * b[0] + a[3] * b[2],
|
||
a[2] * b[1] + a[3] * b[3],
|
||
a[4] * b[0] + a[5] * b[2] + b[4],
|
||
a[4] * b[1] + a[5] * b[3] + b[5],
|
||
]
|
||
}
|
||
|
||
/// The glyph-space-to-page matrix of each text-showing operator (`Tj`/`TJ`), in order: the text
|
||
/// matrix scaled by the font size, under the current transformation matrix.
|
||
pub fn text_matrices(content: &str) -> Vec<[f64; 6]> {
|
||
let mut stack = vec![IDENTITY];
|
||
let (mut tm, mut size) = (IDENTITY, 1.0);
|
||
let mut out = Vec::new();
|
||
for o in ops(content) {
|
||
let ctm = *stack.last().expect("a graphics state");
|
||
match o.op.as_str() {
|
||
"q" => stack.push(ctm),
|
||
"Q" => {
|
||
stack.pop();
|
||
}
|
||
"cm" if o.nums.len() == 6 => {
|
||
let m = [o.nums[0], o.nums[1], o.nums[2], o.nums[3], o.nums[4], o.nums[5]];
|
||
*stack.last_mut().expect("a graphics state") = mul(m, ctm);
|
||
}
|
||
"BT" => tm = IDENTITY,
|
||
"Tm" if o.nums.len() == 6 => tm = [o.nums[0], o.nums[1], o.nums[2], o.nums[3], o.nums[4], o.nums[5]],
|
||
"Tf" => size = o.nums.first().copied().unwrap_or(1.0),
|
||
"Tj" | "TJ" => out.push(mul(mul([size, 0.0, 0.0, size, 0.0, 0.0], tm), ctm)),
|
||
_ => {}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The string operands of text-showing operators (`Tj`), as bytes, in order.
|
||
pub fn shown(content: &[u8]) -> Vec<Vec<u8>> {
|
||
let mut out = Vec::new();
|
||
let mut i = 0;
|
||
while i < content.len() {
|
||
if content[i] == b'(' {
|
||
let mut s = Vec::new();
|
||
let mut depth = 1;
|
||
i += 1;
|
||
while i < content.len() {
|
||
match content[i] {
|
||
b'\\' => {
|
||
i += 1;
|
||
let c = content.get(i).copied().unwrap_or(b'\\');
|
||
if c.is_ascii_digit() {
|
||
let digits: Vec<u8> = content[i..].iter().take(3).take_while(|d| (b'0'..=b'7').contains(d)).copied().collect();
|
||
s.push(u8::from_str_radix(std::str::from_utf8(&digits).unwrap_or("0"), 8).unwrap_or(0));
|
||
i += digits.len() - 1;
|
||
} else {
|
||
s.push(match c {
|
||
b'n' => b'\n',
|
||
b'r' => b'\r',
|
||
b't' => b'\t',
|
||
other => other,
|
||
});
|
||
}
|
||
}
|
||
b'(' => {
|
||
depth += 1;
|
||
s.push(b'(');
|
||
}
|
||
b')' => {
|
||
depth -= 1;
|
||
if depth == 0 {
|
||
break;
|
||
}
|
||
s.push(b')');
|
||
}
|
||
c => s.push(c),
|
||
}
|
||
i += 1;
|
||
}
|
||
let rest = &content[(i + 1).min(content.len())..];
|
||
let next: Vec<u8> = rest.iter().skip_while(|c| c.is_ascii_whitespace()).take(2).copied().collect();
|
||
if next == b"Tj" {
|
||
out.push(s);
|
||
}
|
||
}
|
||
i += 1;
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The `bfchar` entries of a `ToUnicode` CMap: code and text.
|
||
pub fn to_unicode_map(cmap: &str) -> Vec<(u32, String)> {
|
||
let mut out = Vec::new();
|
||
for block in cmap.split("beginbfchar").skip(1) {
|
||
let body = block.split("endbfchar").next().unwrap_or("");
|
||
let hex: Vec<&str> = body.split(['<', '>']).map(str::trim).filter(|t| !t.is_empty()).collect();
|
||
for pair in hex.chunks(2) {
|
||
let [code, text] = pair else { continue };
|
||
let units: Vec<u16> = (0..text.len() / 4).filter_map(|i| u16::from_str_radix(&text[i * 4..i * 4 + 4], 16).ok()).collect();
|
||
if let Ok(c) = u32::from_str_radix(code, 16) {
|
||
out.push((c, String::from_utf16_lossy(&units)));
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|