mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-12 17:02:08 -06:00
PDF: writer cleanup, q nesting limit, image dedupe, style only on change (#816)
Direct object writing, image dedupe, per-page style tracking, 28-level q limit, explicit joins, /ProcSet dropped. See the PR description. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
fc45b7f6d8
commit
e04e559da3
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (PDF writer cleanup, #816) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
@@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | PDF writer: objects are written straight to the output, identical pictures are stored once, style operators are emitted only when they change, `q` nesting is limited to 28 and polyline joins no longer leak into later strokes (#816) |
|
||||
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
|
||||
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
|
||||
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
|
||||
@@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Progress log: PDF writer cleanup (#816) |
|
||||
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
|
||||
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
|
||||
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
|
||||
|
||||
+302
-102
@@ -13,7 +13,10 @@
|
||||
|
||||
mod metrics;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::fmt::Write as _;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
/// RGB colour, 0–255 per channel.
|
||||
pub type Rgb = [u8; 3];
|
||||
@@ -221,16 +224,75 @@ fn n(v: f32) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb_op(c: Rgb, stroke: bool) -> String {
|
||||
let f = |v: u8| n(v as f32 / 255.0);
|
||||
format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" })
|
||||
/// Appends `v / 255` with at most three decimals, exactly what `n(v as f32 / 255.0)` gives, but
|
||||
/// without a float round trip or an allocation per channel.
|
||||
fn push_unit(s: &mut String, v: u8) {
|
||||
let m = (u32::from(v) * 1000 + 127) / 255;
|
||||
match m {
|
||||
0 => s.push('0'),
|
||||
1000 => s.push('1'),
|
||||
_ => {
|
||||
let _ = write!(s, "0.{m:03}");
|
||||
while s.ends_with('0') {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends a colour operator line without the trailing newline: `r g b rg` (fill) or `r g b RG`.
|
||||
fn push_rgb(s: &mut String, c: Rgb, stroke: bool) {
|
||||
for v in c {
|
||||
push_unit(s, v);
|
||||
s.push(' ');
|
||||
}
|
||||
s.push_str(if stroke { "RG" } else { "rg" });
|
||||
}
|
||||
|
||||
/// The dash array contents (`3 2`) of a dash pattern; empty for solid.
|
||||
fn dash_string(dash: &[f32]) -> String {
|
||||
let d: Vec<String> = dash.iter().take(8).map(|v| n(v.max(0.0))).collect();
|
||||
d.join(" ")
|
||||
}
|
||||
|
||||
struct ImageData {
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgb: Vec<u8>,
|
||||
/// `None` when every pixel is fully opaque (no soft mask is written).
|
||||
alpha: Option<Vec<u8>>,
|
||||
/// Hash of all of the above, to find duplicates quickly; equality still compares the bytes.
|
||||
hash: u64,
|
||||
}
|
||||
|
||||
impl ImageData {
|
||||
fn new(width: u32, height: u32, rgb: Vec<u8>, alpha: Option<Vec<u8>>) -> ImageData {
|
||||
let mut h = DefaultHasher::new();
|
||||
width.hash(&mut h);
|
||||
height.hash(&mut h);
|
||||
rgb.hash(&mut h);
|
||||
alpha.hash(&mut h);
|
||||
ImageData { width, height, rgb, alpha, hash: h.finish() }
|
||||
}
|
||||
|
||||
/// Same pixels (a hash match alone is never trusted).
|
||||
fn same(&self, o: &ImageData) -> bool {
|
||||
self.hash == o.hash && self.width == o.width && self.height == o.height && self.rgb == o.rgb && self.alpha == o.alpha
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of the graphics state the writer tracks so that a style operator is only emitted
|
||||
/// when it changes. `None` = not set yet on this page (or since the last restore that undid it).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct Style {
|
||||
fill: Option<Rgb>,
|
||||
stroke: Option<Rgb>,
|
||||
/// Formatted line width.
|
||||
width: Option<String>,
|
||||
/// Formatted dash array contents.
|
||||
dash: Option<String>,
|
||||
/// Line join; PDF's initial value is 0 (miter).
|
||||
join: u8,
|
||||
}
|
||||
|
||||
/// A page under construction. Obtain one with [`PdfDoc::add_page`].
|
||||
@@ -240,7 +302,12 @@ pub struct Page {
|
||||
content: String,
|
||||
fonts: [bool; 12],
|
||||
images: Vec<ImageData>,
|
||||
depth: u32,
|
||||
/// Style operators already in effect at the end of `content`.
|
||||
style: Style,
|
||||
/// One entry per accepted [`Page::save`], restored by [`Page::restore`].
|
||||
saved: Vec<Style>,
|
||||
/// `save` calls refused at the nesting limit; the same number of `restore` calls are ignored.
|
||||
refused: u32,
|
||||
}
|
||||
|
||||
/// Most bytes of content per page before further drawing is dropped (keeps hostile inputs from
|
||||
@@ -248,6 +315,11 @@ pub struct Page {
|
||||
const MAX_PAGE_CONTENT: usize = 64 * 1024 * 1024;
|
||||
/// Largest image (pixels) embedded.
|
||||
const MAX_IMAGE_PIXELS: u64 = 40_000_000;
|
||||
/// Deepest `q` nesting a PDF consumer must support (the PDF specification's implementation limits).
|
||||
const MAX_SAVE_DEPTH: usize = 28;
|
||||
/// Line join styles, as the `j` operator takes them.
|
||||
const JOIN_MITER: u8 = 0;
|
||||
const JOIN_ROUND: u8 = 1;
|
||||
|
||||
impl Page {
|
||||
pub fn width(&self) -> f32 {
|
||||
@@ -269,15 +341,55 @@ impl Page {
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the graphics state (pair with [`Page::restore`]).
|
||||
/// Appends a fill colour operator to `s` unless that colour is already in effect.
|
||||
fn set_fill(&mut self, s: &mut String, c: Rgb) {
|
||||
if self.style.fill != Some(c) {
|
||||
push_rgb(s, c, false);
|
||||
s.push('\n');
|
||||
self.style.fill = Some(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends the stroke colour, width, dash and join operators to `s` that differ from the
|
||||
/// style already in effect.
|
||||
fn set_stroke(&mut self, s: &mut String, c: Rgb, width: f32, dash: &str, join: u8) {
|
||||
if self.style.stroke != Some(c) {
|
||||
push_rgb(s, c, true);
|
||||
s.push('\n');
|
||||
self.style.stroke = Some(c);
|
||||
}
|
||||
let w = n(width);
|
||||
if self.style.width.as_deref() != Some(w.as_str()) {
|
||||
let _ = writeln!(s, "{w} w");
|
||||
self.style.width = Some(w);
|
||||
}
|
||||
if self.style.dash.as_deref() != Some(dash) {
|
||||
let _ = writeln!(s, "[{dash}] 0 d");
|
||||
self.style.dash = Some(dash.to_string());
|
||||
}
|
||||
if self.style.join != join {
|
||||
let _ = writeln!(s, "{join} j");
|
||||
self.style.join = join;
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the graphics state (pair with [`Page::restore`]). Nesting is limited to 28 levels, the
|
||||
/// depth every PDF consumer must support; a `save` beyond that is ignored, and so is the
|
||||
/// `restore` that would have matched it.
|
||||
pub fn save(&mut self) {
|
||||
self.depth = self.depth.saturating_add(1);
|
||||
if self.saved.len() >= MAX_SAVE_DEPTH {
|
||||
self.refused = self.refused.saturating_add(1);
|
||||
return;
|
||||
}
|
||||
self.saved.push(self.style.clone());
|
||||
self.content.push_str("q\n");
|
||||
}
|
||||
/// Restores the graphics state saved by [`Page::save`]; extra calls are ignored.
|
||||
pub fn restore(&mut self) {
|
||||
if self.depth > 0 {
|
||||
self.depth -= 1;
|
||||
if self.refused > 0 {
|
||||
self.refused -= 1;
|
||||
} else if let Some(style) = self.saved.pop() {
|
||||
self.style = style;
|
||||
self.content.push_str("Q\n");
|
||||
}
|
||||
}
|
||||
@@ -288,15 +400,22 @@ impl Page {
|
||||
}
|
||||
|
||||
pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) {
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
if w <= 0.0 || h <= 0.0 || self.full() {
|
||||
return;
|
||||
}
|
||||
let s = format!("{}\n{} {} {} {} re f", rgb_op(color, false), n(x), n(self.y(y + h)), n(w), n(h));
|
||||
let mut s = String::new();
|
||||
self.set_fill(&mut s, color);
|
||||
let _ = write!(s, "{} {} {} {} re f", n(x), n(self.y(y + h)), n(w), n(h));
|
||||
self.op(&s);
|
||||
}
|
||||
|
||||
pub fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, width: f32, color: Rgb) {
|
||||
let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(width), n(x), n(self.y(y + h)), n(w), n(h));
|
||||
if self.full() {
|
||||
return;
|
||||
}
|
||||
let mut s = String::new();
|
||||
self.set_stroke(&mut s, color, width, "", JOIN_MITER);
|
||||
let _ = write!(s, "{} {} {} {} re S", n(x), n(self.y(y + h)), n(w), n(h));
|
||||
self.op(&s);
|
||||
}
|
||||
|
||||
@@ -306,19 +425,22 @@ impl Page {
|
||||
|
||||
/// A line with a dash pattern (`[on, off, …]` in points; empty = solid).
|
||||
pub fn stroke_line_dashed(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb, dash: &[f32]) {
|
||||
let d: Vec<String> = dash.iter().take(8).map(|v| n(v.max(0.0))).collect();
|
||||
let s =
|
||||
format!("{}\n{} w [{}] 0 d\n{} {} m {} {} l S", rgb_op(color, true), n(width), d.join(" "), n(x0), n(self.y(y0)), n(x1), n(self.y(y1)));
|
||||
if self.full() {
|
||||
return;
|
||||
}
|
||||
let mut s = String::new();
|
||||
self.set_stroke(&mut s, color, width, &dash_string(dash), JOIN_MITER);
|
||||
let _ = write!(s, "{} {} m {} {} l S", n(x0), n(self.y(y0)), n(x1), n(self.y(y1)));
|
||||
self.op(&s);
|
||||
}
|
||||
|
||||
/// An open polyline.
|
||||
/// An open polyline (round joins).
|
||||
pub fn polyline(&mut self, pts: &[[f32; 2]], width: f32, color: Rgb, dash: &[f32]) {
|
||||
if pts.len() < 2 {
|
||||
if pts.len() < 2 || self.full() {
|
||||
return;
|
||||
}
|
||||
let d: Vec<String> = dash.iter().take(8).map(|v| n(v.max(0.0))).collect();
|
||||
let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(width), d.join(" "));
|
||||
let mut s = String::new();
|
||||
self.set_stroke(&mut s, color, width, &dash_string(dash), JOIN_ROUND);
|
||||
self.path(&mut s, pts, false);
|
||||
s.push_str(" S");
|
||||
self.op(&s);
|
||||
@@ -333,18 +455,17 @@ impl Page {
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed polygon, filled and/or stroked.
|
||||
/// A closed polygon, filled and/or stroked (round joins).
|
||||
pub fn polygon(&mut self, pts: &[[f32; 2]], fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
|
||||
if pts.len() < 2 || (fill.is_none() && stroke.is_none()) {
|
||||
if pts.len() < 2 || (fill.is_none() && stroke.is_none()) || self.full() {
|
||||
return;
|
||||
}
|
||||
let mut s = String::new();
|
||||
if let Some(f) = fill {
|
||||
s.push_str(&rgb_op(f, false));
|
||||
s.push('\n');
|
||||
self.set_fill(&mut s, f);
|
||||
}
|
||||
if let Some((c, w)) = stroke {
|
||||
let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(w));
|
||||
self.set_stroke(&mut s, c, w, "", JOIN_ROUND);
|
||||
}
|
||||
self.path(&mut s, pts, true);
|
||||
s.push_str(match (fill.is_some(), stroke.is_some()) {
|
||||
@@ -357,7 +478,7 @@ impl Page {
|
||||
|
||||
/// A circle approximated by four cubic Béziers.
|
||||
pub fn circle(&mut self, cx: f32, cy: f32, r: f32, fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
|
||||
if r <= 0.0 || (fill.is_none() && stroke.is_none()) {
|
||||
if r <= 0.0 || (fill.is_none() && stroke.is_none()) || self.full() {
|
||||
return;
|
||||
}
|
||||
const K: f32 = 0.552_284_8;
|
||||
@@ -365,11 +486,10 @@ impl Page {
|
||||
let y = self.y(cy);
|
||||
let mut s = String::new();
|
||||
if let Some(f) = fill {
|
||||
s.push_str(&rgb_op(f, false));
|
||||
s.push('\n');
|
||||
self.set_fill(&mut s, f);
|
||||
}
|
||||
if let Some((c, w)) = stroke {
|
||||
let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(w));
|
||||
self.set_stroke(&mut s, c, w, "", JOIN_MITER);
|
||||
}
|
||||
let _ = write!(
|
||||
s,
|
||||
@@ -424,11 +544,16 @@ impl Page {
|
||||
if let Some(f) = self.fonts.get_mut(fi) {
|
||||
*f = true;
|
||||
}
|
||||
if self.full() {
|
||||
return;
|
||||
}
|
||||
let (sn, cs) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) };
|
||||
// The colour goes in front of the text object, so it is tracked like any other style.
|
||||
let mut colour = String::new();
|
||||
self.set_fill(&mut colour, color);
|
||||
// Clockwise in y-down = counter-clockwise negated in PDF's y-up space.
|
||||
let s = format!(
|
||||
"BT\n{}\n/F{} {} Tf\n{} {} {} {} {} {} Tm\n{} Tj\nET",
|
||||
rgb_op(color, false),
|
||||
"{colour}BT\n/F{} {} Tf\n{} {} {} {} {} {} Tm\n{} Tj\nET",
|
||||
fi + 1,
|
||||
n(size.min(10_000.0)),
|
||||
n(cs),
|
||||
@@ -449,16 +574,36 @@ impl Page {
|
||||
if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || w <= 0.0 || h <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// The image is drawn inside its own q/Q, which counts towards the nesting limit.
|
||||
if self.saved.len() >= MAX_SAVE_DEPTH {
|
||||
return;
|
||||
}
|
||||
let px = px as usize;
|
||||
let mut rgb = Vec::with_capacity(px * 3);
|
||||
let mut alpha = Vec::with_capacity(px);
|
||||
for p in rgba.as_chunks::<4>().0.iter().take(px) {
|
||||
// The alpha plane is only allocated once a non-opaque pixel shows up.
|
||||
let mut alpha: Option<Vec<u8>> = None;
|
||||
for (i, p) in rgba.as_chunks::<4>().0.iter().take(px).enumerate() {
|
||||
rgb.extend_from_slice(&[p[0], p[1], p[2]]);
|
||||
alpha.push(p[3]);
|
||||
match &mut alpha {
|
||||
Some(a) => a.push(p[3]),
|
||||
None if p[3] != 255 => {
|
||||
let mut a = Vec::with_capacity(px);
|
||||
a.resize(i, 255);
|
||||
a.push(p[3]);
|
||||
alpha = Some(a);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
let alpha = if alpha.iter().all(|a| *a == 255) { None } else { Some(alpha) };
|
||||
let idx = self.images.len();
|
||||
self.images.push(ImageData { width: pw, height: ph, rgb, alpha });
|
||||
let img = ImageData::new(pw, ph, rgb, alpha);
|
||||
// The same picture drawn again on this page shares one XObject.
|
||||
let idx = match self.images.iter().position(|i| i.same(&img)) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.images.push(img);
|
||||
self.images.len() - 1
|
||||
}
|
||||
};
|
||||
let s = format!("q {} 0 0 {} {} {} cm /Im{} Do Q", n(w), n(h), n(x), n(self.y(y + h)), idx + 1);
|
||||
self.op(&s);
|
||||
}
|
||||
@@ -520,6 +665,49 @@ fn pdf_text_string(s: &str) -> String {
|
||||
pdf_string(s)
|
||||
}
|
||||
|
||||
/// Starts object `id` at the current end of `out` and records its offset for the xref table.
|
||||
fn begin_obj(out: &mut Vec<u8>, offsets: &mut Vec<usize>, id: u32) {
|
||||
let i = id as usize;
|
||||
if offsets.len() <= i {
|
||||
offsets.resize(i + 1, 0);
|
||||
}
|
||||
if let Some(slot) = offsets.get_mut(i) {
|
||||
*slot = out.len();
|
||||
}
|
||||
let _ = writeln!(Utf8(out), "{id} 0 obj");
|
||||
}
|
||||
|
||||
/// Writes `id 0 obj <body> endobj`.
|
||||
fn dict_obj(out: &mut Vec<u8>, offsets: &mut Vec<usize>, id: u32, body: &str) {
|
||||
begin_obj(out, offsets, id);
|
||||
out.extend_from_slice(body.as_bytes());
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
|
||||
/// Writes a stream object: `dict` (the dictionary entries without `<<`, `>>` and `/Length`, may be
|
||||
/// empty) and `data` as the stream body.
|
||||
fn stream_obj(out: &mut Vec<u8>, offsets: &mut Vec<usize>, id: u32, dict: &str, data: &[u8]) {
|
||||
begin_obj(out, offsets, id);
|
||||
out.extend_from_slice(b"<<");
|
||||
if !dict.is_empty() {
|
||||
out.push(b' ');
|
||||
out.extend_from_slice(dict.as_bytes());
|
||||
}
|
||||
let _ = write!(Utf8(out), " /Length {} >>\nstream\n", data.len());
|
||||
out.extend_from_slice(data);
|
||||
out.extend_from_slice(b"\nendstream\nendobj\n");
|
||||
}
|
||||
|
||||
/// Lets `write!` append text to a byte buffer.
|
||||
struct Utf8<'a>(&'a mut Vec<u8>);
|
||||
|
||||
impl std::fmt::Write for Utf8<'_> {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
self.0.extend_from_slice(s.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PdfDoc {
|
||||
pub fn new() -> PdfDoc {
|
||||
PdfDoc::default()
|
||||
@@ -529,7 +717,16 @@ impl PdfDoc {
|
||||
pub fn add_page(&mut self, width_pt: f32, height_pt: f32) -> &mut Page {
|
||||
let width = if width_pt.is_finite() { width_pt.clamp(3.0, 14_400.0) } else { 612.0 };
|
||||
let height = if height_pt.is_finite() { height_pt.clamp(3.0, 14_400.0) } else { 792.0 };
|
||||
self.pages.push(Page { width, height, content: String::new(), fonts: [false; 12], images: Vec::new(), depth: 0 });
|
||||
self.pages.push(Page {
|
||||
width,
|
||||
height,
|
||||
content: String::new(),
|
||||
fonts: [false; 12],
|
||||
images: Vec::new(),
|
||||
style: Style::default(),
|
||||
saved: Vec::new(),
|
||||
refused: 0,
|
||||
});
|
||||
let i = self.pages.len() - 1;
|
||||
&mut self.pages[i]
|
||||
}
|
||||
@@ -544,10 +741,22 @@ impl PdfDoc {
|
||||
}
|
||||
|
||||
/// Serializes the document. A document without pages gets one blank Letter page.
|
||||
///
|
||||
/// Every object is written straight into the output buffer (nothing is staged in an
|
||||
/// intermediate list of bodies); only the xref table needs the offset of each object id, and
|
||||
/// the ids are contiguous from 1. Objects are therefore in the file in the order they are
|
||||
/// numbered during writing (fonts, then per page its new images, content stream and page
|
||||
/// dictionary), with the catalog, page tree and info dictionary last.
|
||||
pub fn finish(mut self) -> Vec<u8> {
|
||||
if self.pages.is_empty() {
|
||||
self.add_page(612.0, 792.0);
|
||||
}
|
||||
for page in &mut self.pages {
|
||||
page.refused = 0;
|
||||
while !page.saved.is_empty() {
|
||||
page.restore();
|
||||
}
|
||||
}
|
||||
// Object numbering: 1 catalog, 2 page tree, 3 info, then fonts, then per page.
|
||||
let mut used = [false; 12];
|
||||
for p in &self.pages {
|
||||
@@ -563,7 +772,12 @@ impl PdfDoc {
|
||||
next += 1;
|
||||
}
|
||||
}
|
||||
let mut objects: Vec<(u32, Vec<u8>)> = Vec::new();
|
||||
// Page contents are moved out so each one is freed as soon as it is in the output.
|
||||
let contents: Vec<String> = self.pages.iter_mut().map(|p| std::mem::take(&mut p.content)).collect();
|
||||
let reserve = contents.iter().fold(4096usize, |acc, c| acc.saturating_add(c.len()).saturating_add(1024));
|
||||
let mut out: Vec<u8> = Vec::with_capacity(reserve);
|
||||
out.extend_from_slice(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
|
||||
let mut offsets: Vec<usize> = vec![0; 4];
|
||||
let mut kids = Vec::new();
|
||||
let mut font_dict = String::new();
|
||||
for (i, id) in font_ids.iter().enumerate() {
|
||||
@@ -571,76 +785,75 @@ impl PdfDoc {
|
||||
let _ = write!(font_dict, "/F{} {} 0 R ", i + 1, id);
|
||||
let name = Font::ALL.get(i).copied().unwrap_or_default().base_name();
|
||||
let enc = if name.starts_with("Symbol") { "" } else { " /Encoding /WinAnsiEncoding" };
|
||||
objects.push((*id, format!("<< /Type /Font /Subtype /Type1 /BaseFont /{name}{enc} >>").into_bytes()));
|
||||
dict_obj(&mut out, &mut offsets, *id, &format!("<< /Type /Font /Subtype /Type1 /BaseFont /{name}{enc} >>"));
|
||||
}
|
||||
}
|
||||
for page in &mut self.pages {
|
||||
while page.depth > 0 {
|
||||
page.restore();
|
||||
}
|
||||
// Images already written, by content hash: (page, image, object id). A hash match is
|
||||
// confirmed by comparing the pixels before an object is shared.
|
||||
let mut written: HashMap<u64, Vec<(usize, usize, u32)>> = HashMap::new();
|
||||
for (pi, (page, content)) in self.pages.iter().zip(contents).enumerate() {
|
||||
let mut xobjects = String::new();
|
||||
for (k, img) in page.images.iter().enumerate() {
|
||||
let img_id = next;
|
||||
next += 1;
|
||||
let smask = match &img.alpha {
|
||||
Some(a) => {
|
||||
let id = next;
|
||||
let known = written.get(&img.hash).and_then(|c| {
|
||||
c.iter().find_map(|(p, i, id)| {
|
||||
let other = self.pages.get(*p)?.images.get(*i)?;
|
||||
img.same(other).then_some(*id)
|
||||
})
|
||||
});
|
||||
let img_id = match known {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let img_id = next;
|
||||
next += 1;
|
||||
let data = run_length(a);
|
||||
let mut o = format!(
|
||||
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /RunLengthDecode /Length {} >>\nstream\n",
|
||||
img.width,
|
||||
img.height,
|
||||
data.len()
|
||||
)
|
||||
.into_bytes();
|
||||
o.extend_from_slice(&data);
|
||||
o.extend_from_slice(b"\nendstream");
|
||||
objects.push((id, o));
|
||||
format!(" /SMask {id} 0 R")
|
||||
let mut dict = format!("/Type /XObject /Subtype /Image /Width {} /Height {} /BitsPerComponent 8", img.width, img.height);
|
||||
let smask_id = img.alpha.as_ref().map(|_| {
|
||||
let id = next;
|
||||
next += 1;
|
||||
id
|
||||
});
|
||||
dict.push_str(" /ColorSpace /DeviceRGB");
|
||||
if let Some(id) = smask_id {
|
||||
let _ = write!(dict, " /SMask {id} 0 R");
|
||||
}
|
||||
dict.push_str(" /Filter /RunLengthDecode");
|
||||
stream_obj(&mut out, &mut offsets, img_id, &dict, &run_length(&img.rgb));
|
||||
if let (Some(id), Some(a)) = (smask_id, &img.alpha) {
|
||||
let dict = format!(
|
||||
"/Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /RunLengthDecode",
|
||||
img.width, img.height
|
||||
);
|
||||
stream_obj(&mut out, &mut offsets, id, &dict, &run_length(a));
|
||||
}
|
||||
written.entry(img.hash).or_default().push((pi, k, img_id));
|
||||
img_id
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
let data = run_length(&img.rgb);
|
||||
let mut o = format!(
|
||||
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceRGB /BitsPerComponent 8{} /Filter /RunLengthDecode /Length {} >>\nstream\n",
|
||||
img.width,
|
||||
img.height,
|
||||
smask,
|
||||
data.len()
|
||||
)
|
||||
.into_bytes();
|
||||
o.extend_from_slice(&data);
|
||||
o.extend_from_slice(b"\nendstream");
|
||||
objects.push((img_id, o));
|
||||
let _ = write!(xobjects, "/Im{} {} 0 R ", k + 1, img_id);
|
||||
}
|
||||
let content_id = next;
|
||||
let page_id = next + 1;
|
||||
next += 2;
|
||||
let body = std::mem::take(&mut page.content);
|
||||
let mut o = format!("<< /Length {} >>\nstream\n", body.len()).into_bytes();
|
||||
o.extend_from_slice(body.as_bytes());
|
||||
o.extend_from_slice(b"endstream");
|
||||
objects.push((content_id, o));
|
||||
stream_obj(&mut out, &mut offsets, content_id, "", content.as_bytes());
|
||||
drop(content);
|
||||
let xo = if xobjects.is_empty() { String::new() } else { format!(" /XObject << {xobjects}>>") };
|
||||
objects.push((
|
||||
dict_obj(
|
||||
&mut out,
|
||||
&mut offsets,
|
||||
page_id,
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources << /ProcSet [/PDF /Text /ImageB /ImageC] /Font << {}>>{} >> /Contents {} 0 R >>",
|
||||
&format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources << /Font << {}>>{} >> /Contents {} 0 R >>",
|
||||
n(page.width),
|
||||
n(page.height),
|
||||
font_dict,
|
||||
xo,
|
||||
content_id
|
||||
)
|
||||
.into_bytes(),
|
||||
));
|
||||
),
|
||||
);
|
||||
kids.push(page_id);
|
||||
}
|
||||
objects.push((1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()));
|
||||
dict_obj(&mut out, &mut offsets, 1, "<< /Type /Catalog /Pages 2 0 R >>");
|
||||
let kids_s: Vec<String> = kids.iter().map(|k| format!("{k} 0 R")).collect();
|
||||
objects.push((2, format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids_s.join(" "), kids.len()).into_bytes()));
|
||||
dict_obj(&mut out, &mut offsets, 2, &format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids_s.join(" "), kids.len()));
|
||||
let creator = if self.info.creator.is_empty() { "GridCraft" } else { &self.info.creator };
|
||||
let mut info = format!("<< /Producer {} /Creator {}", pdf_text_string("GridCraft PDF"), pdf_text_string(creator));
|
||||
if !self.info.title.is_empty() {
|
||||
@@ -650,27 +863,14 @@ impl PdfDoc {
|
||||
let _ = write!(info, " /Author {}", pdf_text_string(&self.info.author));
|
||||
}
|
||||
info.push_str(" >>");
|
||||
objects.push((3, info.into_bytes()));
|
||||
objects.sort_by_key(|(id, _)| *id);
|
||||
dict_obj(&mut out, &mut offsets, 3, &info);
|
||||
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
out.extend_from_slice(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
|
||||
let mut offsets = vec![0usize; next as usize];
|
||||
for (id, body) in &objects {
|
||||
if let Some(slot) = offsets.get_mut(*id as usize) {
|
||||
*slot = out.len();
|
||||
}
|
||||
out.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
let mut x = format!("xref\n0 {}\n0000000000 65535 f \n", next);
|
||||
let _ = write!(Utf8(&mut out), "xref\n0 {}\n0000000000 65535 f \n", next);
|
||||
for off in offsets.iter().skip(1) {
|
||||
let _ = writeln!(x, "{off:010} 00000 n ");
|
||||
let _ = writeln!(Utf8(&mut out), "{off:010} 00000 n ");
|
||||
}
|
||||
let _ = write!(x, "trailer\n<< /Size {} /Root 1 0 R /Info 3 0 R >>\nstartxref\n{}\n%%EOF\n", next, xref);
|
||||
out.extend_from_slice(x.as_bytes());
|
||||
let _ = write!(Utf8(&mut out), "trailer\n<< /Size {} /Root 1 0 R /Info 3 0 R >>\nstartxref\n{}\n%%EOF\n", next, xref);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::*;
|
||||
|
||||
mod reference;
|
||||
use reference::{OldPage, interpret};
|
||||
|
||||
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
hay.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
@@ -157,3 +160,530 @@ fn hostile_inputs_never_panic() {
|
||||
let pdf = d.finish();
|
||||
check_xref(&pdf);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Writer cleanup: equivalence with the previous content writer, structure, and one test per
|
||||
// behaviour change.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Offsets of the in-use objects, by id, from the xref table.
|
||||
fn xref_offsets(pdf: &[u8]) -> Vec<usize> {
|
||||
let sx = rfind(pdf, b"startxref\n").unwrap();
|
||||
let rest = std::str::from_utf8(&pdf[sx + 10..]).unwrap();
|
||||
let off: usize = rest.lines().next().unwrap().trim().parse().unwrap();
|
||||
let table = std::str::from_utf8(&pdf[off..sx]).unwrap();
|
||||
table.lines().skip(2).take_while(|l| l.len() == 18 || l.len() == 19).map(|e| e[..10].parse().unwrap()).collect()
|
||||
}
|
||||
|
||||
struct Obj {
|
||||
dict: String,
|
||||
stream: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Parses every object of a document written by this crate, checking that each stream's
|
||||
/// `/Length` matches its data and is followed by `endstream`/`endobj`.
|
||||
fn parse_objects(pdf: &[u8]) -> Vec<Obj> {
|
||||
let offsets = xref_offsets(pdf);
|
||||
let mut objs = Vec::new();
|
||||
for (id, off) in offsets.iter().enumerate() {
|
||||
if id == 0 {
|
||||
objs.push(Obj { dict: String::new(), stream: None });
|
||||
continue;
|
||||
}
|
||||
let tail = &pdf[*off..];
|
||||
let head = format!("{id} 0 obj\n");
|
||||
assert!(tail.starts_with(head.as_bytes()), "object {id}");
|
||||
let body = &tail[head.len()..];
|
||||
let end = find(body, b"\nendobj\n").unwrap();
|
||||
match find(body, b"\nstream\n").filter(|s| *s < end) {
|
||||
Some(s) => {
|
||||
let dict = String::from_utf8(body[..s].to_vec()).unwrap();
|
||||
let l = dict.find("/Length ").unwrap() + 8;
|
||||
let len: usize = dict[l..].split(' ').next().unwrap().parse().unwrap();
|
||||
let data = &body[s + 8..];
|
||||
assert!(data[len..].starts_with(b"\nendstream\nendobj\n"), "object {id}: /Length {len} is wrong");
|
||||
objs.push(Obj { dict, stream: Some(data[..len].to_vec()) });
|
||||
}
|
||||
None => objs.push(Obj { dict: String::from_utf8(body[..end].to_vec()).unwrap(), stream: None }),
|
||||
}
|
||||
}
|
||||
objs
|
||||
}
|
||||
|
||||
fn ref_of(dict: &str, key: &str) -> Option<usize> {
|
||||
let i = dict.find(key)? + key.len();
|
||||
dict[i..].trim_start().split(' ').next()?.parse().ok()
|
||||
}
|
||||
|
||||
struct ParsedPage {
|
||||
media: String,
|
||||
content: String,
|
||||
/// `(Im name, object id)`.
|
||||
xobjects: Vec<(String, usize)>,
|
||||
}
|
||||
|
||||
fn parse_pages(pdf: &[u8]) -> (Vec<Obj>, Vec<ParsedPage>) {
|
||||
let objs = parse_objects(pdf);
|
||||
let mut pages = Vec::new();
|
||||
for o in &objs {
|
||||
if !o.dict.contains("/Type /Page ") {
|
||||
continue;
|
||||
}
|
||||
let m = o.dict.find("/MediaBox [").unwrap() + 10;
|
||||
let media = o.dict[m..m + o.dict[m..].find(']').unwrap() + 1].to_string();
|
||||
let content_id = ref_of(&o.dict, "/Contents").unwrap();
|
||||
let content = String::from_utf8(objs[content_id].stream.clone().unwrap()).unwrap();
|
||||
let mut xobjects = Vec::new();
|
||||
if let Some(x) = o.dict.find("/XObject << ") {
|
||||
let rest = &o.dict[x + 11..];
|
||||
let rest = &rest[..rest.find(">>").unwrap()];
|
||||
let toks: Vec<&str> = rest.split_whitespace().collect();
|
||||
for c in toks.chunks(4) {
|
||||
xobjects.push((c[0].to_string(), c[1].parse().unwrap()));
|
||||
}
|
||||
}
|
||||
pages.push(ParsedPage { media, content, xobjects });
|
||||
}
|
||||
(objs, pages)
|
||||
}
|
||||
|
||||
fn rle_decode(d: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < d.len() {
|
||||
let l = d[i];
|
||||
i += 1;
|
||||
if l == 128 {
|
||||
break;
|
||||
} else if l < 128 {
|
||||
out.extend_from_slice(&d[i..i + l as usize + 1]);
|
||||
i += l as usize + 1;
|
||||
} else {
|
||||
out.extend(std::iter::repeat_n(d[i], 257 - l as usize));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Decodes an image XObject: `(width, height, rgb, alpha)`.
|
||||
fn decode_image(objs: &[Obj], id: usize) -> (u32, u32, Vec<u8>, Option<Vec<u8>>) {
|
||||
let o = &objs[id];
|
||||
let w = ref_of(&o.dict, "/Width").unwrap() as u32;
|
||||
let h = ref_of(&o.dict, "/Height").unwrap() as u32;
|
||||
let rgb = rle_decode(o.stream.as_ref().unwrap());
|
||||
let alpha = ref_of(&o.dict, "/SMask").map(|m| rle_decode(objs[m].stream.as_ref().unwrap()));
|
||||
(w, h, rgb, alpha)
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random numbers for the corpus.
|
||||
struct Lcg(u64);
|
||||
impl Lcg {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
self.0 >> 33
|
||||
}
|
||||
fn below(&mut self, n: u64) -> u64 {
|
||||
self.next() % n
|
||||
}
|
||||
fn f(&mut self) -> f32 {
|
||||
self.below(600) as f32 + self.below(4) as f32 * 0.25
|
||||
}
|
||||
}
|
||||
|
||||
const PALETTE: [Rgb; 4] = [[0, 0, 0], [255, 0, 0], [12, 200, 99], [255, 255, 255]];
|
||||
const WIDTHS: [f32; 3] = [0.5, 1.0, 2.5];
|
||||
const DASHES: [&[f32]; 3] = [&[], &[3.0, 2.0], &[1.0]];
|
||||
|
||||
/// A picture used by the corpus: `(rgba, w, h)`.
|
||||
fn pictures() -> Vec<(Vec<u8>, u32, u32)> {
|
||||
let mut with_alpha = Vec::new();
|
||||
for i in 0..16u8 {
|
||||
with_alpha.extend_from_slice(&[i * 10, 0, 7, if i < 8 { 255 } else { 128 }]);
|
||||
}
|
||||
let opaque: Vec<u8> = (0..4 * 9).map(|i| if i % 4 == 3 { 255 } else { (i * 7) as u8 }).collect();
|
||||
let mut last_px = vec![200u8; 4 * 6];
|
||||
for px in last_px.chunks_mut(4) {
|
||||
px[3] = 255;
|
||||
}
|
||||
last_px[4 * 5 + 3] = 3; // only the very last pixel is translucent
|
||||
vec![(with_alpha, 4, 4), (opaque, 3, 3), (last_px, 3, 2)]
|
||||
}
|
||||
|
||||
/// Applies a random operation to both writers.
|
||||
fn random_op(rng: &mut Lcg, new: &mut Page, old: &mut OldPage, pics: &[(Vec<u8>, u32, u32)], depth: &mut usize) {
|
||||
let color = PALETTE[rng.below(4) as usize];
|
||||
let width = WIDTHS[rng.below(3) as usize];
|
||||
let dash = DASHES[rng.below(3) as usize];
|
||||
let (x, y, w, h) = (rng.f(), rng.f(), rng.f() / 4.0 + 1.0, rng.f() / 4.0 + 1.0);
|
||||
let pts = [[x, y], [x + w, y], [x + w, y + h]];
|
||||
let stroke = if rng.below(2) == 0 { Some((PALETTE[rng.below(4) as usize], WIDTHS[rng.below(3) as usize])) } else { None };
|
||||
let fill = if rng.below(2) == 0 { Some(color) } else { None };
|
||||
match rng.below(14) {
|
||||
0 | 1 => {
|
||||
new.fill_rect(x, y, w, h, color);
|
||||
old.fill_rect(x, y, w, h, color);
|
||||
}
|
||||
2 => {
|
||||
new.rect_stroke(x, y, w, h, width, color);
|
||||
old.rect_stroke(x, y, w, h, width, color);
|
||||
}
|
||||
3 => {
|
||||
new.stroke_line_dashed(x, y, x + w, y + h, width, color, dash);
|
||||
old.stroke_line_dashed(x, y, x + w, y + h, width, color, dash);
|
||||
}
|
||||
4 => {
|
||||
new.polyline(&pts, width, color, dash);
|
||||
old.polyline(&pts, width, color, dash);
|
||||
}
|
||||
5 => {
|
||||
new.polygon(&pts, fill, stroke);
|
||||
old.polygon(&pts, fill, stroke);
|
||||
}
|
||||
6 => {
|
||||
new.circle(x, y, w, fill, stroke);
|
||||
old.circle(x, y, w, fill, stroke);
|
||||
}
|
||||
7 | 8 => {
|
||||
let font = Font::ALL[rng.below(12) as usize];
|
||||
let angle = if rng.below(3) == 0 { 0.5 } else { 0.0 };
|
||||
new.text_rotated(x, y, 10.0, font, color, "Hi (there)", angle);
|
||||
old.text_rotated(x, y, 10.0, font, color, "Hi (there)", angle);
|
||||
}
|
||||
9 => {
|
||||
new.clip_rect(x, y, w, h);
|
||||
old.clip_rect(x, y, w, h);
|
||||
}
|
||||
10 if *depth < 20 => {
|
||||
new.save();
|
||||
old.save();
|
||||
*depth += 1;
|
||||
}
|
||||
11 if *depth > 0 => {
|
||||
new.restore();
|
||||
old.restore();
|
||||
*depth -= 1;
|
||||
}
|
||||
12 => {
|
||||
let (px, pw, ph) = &pics[rng.below(3) as usize];
|
||||
new.image_rgba(x, y, w, h, px, *pw, *ph);
|
||||
old.image(x, y, w, h);
|
||||
}
|
||||
_ => {
|
||||
new.text(x, y, 9.0, Font::Helvetica, color, "Plain");
|
||||
old.text_rotated(x, y, 9.0, Font::Helvetica, color, "Plain", 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the same random multi-page document with the new writer and the old content writer.
|
||||
fn corpus_doc(seed: u64) -> (Vec<u8>, Vec<String>) {
|
||||
let mut rng = Lcg(seed);
|
||||
let pics = pictures();
|
||||
let mut doc = PdfDoc::new();
|
||||
let mut old_pages = Vec::new();
|
||||
for _ in 0..(1 + rng.below(4)) {
|
||||
let mut old = OldPage::new(792.0);
|
||||
let mut depth = 0usize;
|
||||
let page = doc.add_page(612.0, 792.0);
|
||||
for _ in 0..120 {
|
||||
random_op(&mut rng, page, &mut old, &pics, &mut depth);
|
||||
}
|
||||
while depth > 0 {
|
||||
page.restore();
|
||||
old.restore();
|
||||
depth -= 1;
|
||||
}
|
||||
old_pages.push(old.content);
|
||||
}
|
||||
(doc.finish(), old_pages)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_writer_is_equivalent_to_the_old_one() {
|
||||
let mut style_new = 0;
|
||||
let mut style_old = 0;
|
||||
for seed in 1..=40u64 {
|
||||
let (pdf, old_pages) = corpus_doc(seed);
|
||||
check_xref(&pdf);
|
||||
let (_, pages) = parse_pages(&pdf);
|
||||
assert_eq!(pages.len(), old_pages.len());
|
||||
for (p, old) in pages.iter().zip(&old_pages) {
|
||||
assert_eq!(p.media, "[0 0 612 792]");
|
||||
let new = interpret(&p.content);
|
||||
let old = interpret(old);
|
||||
assert!(new.balanced && old.balanced, "q/Q balanced (seed {seed})");
|
||||
assert_eq!(new.paints.len(), old.paints.len(), "seed {seed}");
|
||||
for (i, (a, b)) in new.paints.iter().zip(&old.paints).enumerate() {
|
||||
// Round joins leaking from polylines into later rectangles is the old bug (B7),
|
||||
// compared separately in `join_does_not_leak_from_polylines`.
|
||||
let mut a = a.clone();
|
||||
let mut b = b.clone();
|
||||
a.join.clear();
|
||||
b.join.clear();
|
||||
assert_eq!(a, b, "seed {seed}, painting op {i}");
|
||||
}
|
||||
style_new += new.style_ops;
|
||||
style_old += old.style_ops;
|
||||
}
|
||||
}
|
||||
// Sanity: the corpus really contains style changes, and the new writer emits fewer.
|
||||
assert!(style_old > 1000, "corpus too small: {style_old}");
|
||||
assert!(style_new < style_old, "{style_new} vs {style_old}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structure_is_valid_for_all_documents() {
|
||||
for seed in 1..=40u64 {
|
||||
let (pdf, _) = corpus_doc(seed);
|
||||
check_xref(&pdf);
|
||||
parse_objects(&pdf); // every /Length matches its data
|
||||
}
|
||||
let empty = PdfDoc::new().finish();
|
||||
check_xref(&empty);
|
||||
parse_objects(&empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_decode_to_their_pixels() {
|
||||
let pics = pictures();
|
||||
let mut d = PdfDoc::new();
|
||||
for _ in 0..2 {
|
||||
let p = d.add_page(300.0, 300.0);
|
||||
for (px, w, h) in &pics {
|
||||
p.image_rgba(1.0, 1.0, 20.0, 20.0, px, *w, *h);
|
||||
}
|
||||
}
|
||||
let pdf = d.finish();
|
||||
let (objs, pages) = parse_pages(&pdf);
|
||||
assert_eq!(pages.len(), 2);
|
||||
for page in &pages {
|
||||
assert_eq!(page.xobjects.len(), 3);
|
||||
for ((px, w, h), (_, id)) in pics.iter().zip(&page.xobjects) {
|
||||
let (dw, dh, rgb, alpha) = decode_image(&objs, *id);
|
||||
assert_eq!((dw, dh), (*w, *h));
|
||||
let want_rgb: Vec<u8> = px.chunks(4).flat_map(|c| [c[0], c[1], c[2]]).collect();
|
||||
assert_eq!(rgb, want_rgb);
|
||||
let want_alpha: Vec<u8> = px.chunks(4).map(|c| c[3]).collect();
|
||||
match alpha {
|
||||
Some(a) => assert_eq!(a, want_alpha),
|
||||
None => assert!(want_alpha.iter().all(|a| *a == 255)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// O6: no alpha plane (and no SMask object) for an opaque image; a single translucent pixel
|
||||
/// anywhere (first, middle, last) produces the full plane.
|
||||
#[test]
|
||||
fn opaque_images_have_no_soft_mask() {
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(100.0, 100.0);
|
||||
p.image_rgba(0.0, 0.0, 10.0, 10.0, &[255; 4 * 12], 4, 3);
|
||||
assert!(p.images[0].alpha.is_none());
|
||||
let pdf = d.finish();
|
||||
assert!(find(&pdf, b"/SMask").is_none());
|
||||
assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 1);
|
||||
|
||||
for translucent in [0usize, 5, 11] {
|
||||
let mut px = vec![255u8; 4 * 12];
|
||||
px[translucent * 4 + 3] = 7;
|
||||
let mut d = PdfDoc::new();
|
||||
d.add_page(100.0, 100.0).image_rgba(0.0, 0.0, 10.0, 10.0, &px, 4, 3);
|
||||
let (objs, pages) = parse_pages(&d.finish());
|
||||
let (_, _, _, alpha) = decode_image(&objs, pages[0].xobjects[0].1);
|
||||
let mut want = vec![255u8; 12];
|
||||
want[translucent] = 7;
|
||||
assert_eq!(alpha, Some(want));
|
||||
}
|
||||
}
|
||||
|
||||
/// O4: the same picture on five pages is stored once (image + mask), by every page.
|
||||
#[test]
|
||||
fn identical_images_are_stored_once() {
|
||||
let pics = pictures();
|
||||
let (px, w, h) = &pics[0]; // has alpha: image object + mask object
|
||||
let mut d = PdfDoc::new();
|
||||
for _ in 0..5 {
|
||||
let p = d.add_page(200.0, 200.0);
|
||||
p.image_rgba(1.0, 1.0, 20.0, 20.0, px, *w, *h);
|
||||
p.image_rgba(30.0, 1.0, 20.0, 20.0, px, *w, *h); // twice on one page
|
||||
}
|
||||
let pdf = d.finish();
|
||||
check_xref(&pdf);
|
||||
assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 2, "one image + one soft mask");
|
||||
let (_, pages) = parse_pages(&pdf);
|
||||
let ids: Vec<usize> = pages.iter().flat_map(|p| p.xobjects.iter().map(|x| x.1)).collect();
|
||||
assert_eq!(ids.len(), 5, "one XObject entry per page");
|
||||
assert!(ids.iter().all(|i| *i == ids[0]));
|
||||
for p in &pages {
|
||||
assert_eq!(p.content.matches(" Do").count(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
/// O4: a picture that differs in one byte, or only in size, is not merged.
|
||||
#[test]
|
||||
fn different_images_are_not_merged() {
|
||||
let a = vec![50u8; 4 * 4];
|
||||
let mut b = a.clone();
|
||||
b[5] = 51;
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(100.0, 100.0);
|
||||
p.image_rgba(0.0, 0.0, 5.0, 5.0, &a, 4, 1);
|
||||
p.image_rgba(0.0, 0.0, 5.0, 5.0, &b, 4, 1);
|
||||
p.image_rgba(0.0, 0.0, 5.0, 5.0, &a, 2, 2);
|
||||
d.add_page(100.0, 100.0).image_rgba(0.0, 0.0, 5.0, 5.0, &a, 4, 1);
|
||||
let pdf = d.finish();
|
||||
assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 6, "three distinct pictures, each with a soft mask (alpha 50)");
|
||||
}
|
||||
|
||||
/// O4: equal hashes alone never merge images; the pixels are compared.
|
||||
#[test]
|
||||
fn hash_collisions_do_not_merge_images() {
|
||||
let mut d = PdfDoc::new();
|
||||
for v in [10u8, 20] {
|
||||
d.add_page(100.0, 100.0).image_rgba(0.0, 0.0, 5.0, 5.0, &[v; 16], 2, 2);
|
||||
}
|
||||
for p in 0..2 {
|
||||
d.page_mut(p).unwrap().images[0].hash = 42; // forced collision
|
||||
}
|
||||
let (objs, pages) = parse_pages(&d.finish());
|
||||
let ids: Vec<usize> = pages.iter().map(|p| p.xobjects[0].1).collect();
|
||||
assert_ne!(ids[0], ids[1]);
|
||||
assert_eq!(decode_image(&objs, ids[0]).2, vec![10; 12]);
|
||||
assert_eq!(decode_image(&objs, ids[1]).2, vec![20; 12]);
|
||||
}
|
||||
|
||||
/// S5: the colour component table equals the old float formatting for every byte.
|
||||
#[test]
|
||||
fn colour_components_match_the_float_formatting() {
|
||||
for v in 0..=255u8 {
|
||||
let mut s = String::new();
|
||||
push_unit(&mut s, v);
|
||||
assert_eq!(s, n(v as f32 / 255.0), "byte {v}");
|
||||
}
|
||||
let mut s = String::new();
|
||||
push_rgb(&mut s, [255, 128, 0], true);
|
||||
assert_eq!(s, "1 0.502 0 RG");
|
||||
}
|
||||
|
||||
/// S3: the obsolete /ProcSet entry is gone.
|
||||
#[test]
|
||||
fn no_procset() {
|
||||
let (pdf, _) = corpus_doc(3);
|
||||
assert!(find(&pdf, b"/ProcSet").is_none());
|
||||
}
|
||||
|
||||
/// O3: a style that is already in effect is not emitted again.
|
||||
#[test]
|
||||
fn unchanged_style_is_emitted_once() {
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(200.0, 200.0);
|
||||
for i in 0..50 {
|
||||
p.rect_stroke(i as f32, 0.0, 5.0, 5.0, 1.5, [10, 20, 30]);
|
||||
}
|
||||
for i in 0..50 {
|
||||
p.stroke_line_dashed(0.0, i as f32, 9.0, 9.0, 1.5, [10, 20, 30], &[2.0, 1.0]);
|
||||
}
|
||||
for _ in 0..50 {
|
||||
p.fill_rect(0.0, 0.0, 4.0, 4.0, [1, 2, 3]);
|
||||
p.text(5.0, 5.0, 9.0, Font::Helvetica, [1, 2, 3], "x");
|
||||
}
|
||||
let s = interpret(&p.content);
|
||||
assert_eq!(p.content.matches(" RG\n").count(), 1);
|
||||
assert_eq!(p.content.matches(" rg\n").count(), 1);
|
||||
assert_eq!(p.content.matches(" w\n").count(), 1);
|
||||
assert_eq!(p.content.matches(" d\n").count(), 2, "solid, then [2 1]");
|
||||
assert_eq!(s.style_ops, 5);
|
||||
assert_eq!(s.paints.len(), 50 * 4);
|
||||
}
|
||||
|
||||
/// O3: the tracked style follows q/Q, and a new page starts from scratch.
|
||||
#[test]
|
||||
fn style_tracking_follows_save_restore_and_pages() {
|
||||
let red = [255, 0, 0];
|
||||
let blue = [0, 0, 255];
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(100.0, 100.0);
|
||||
p.fill_rect(0.0, 0.0, 5.0, 5.0, red);
|
||||
p.save();
|
||||
p.fill_rect(0.0, 0.0, 5.0, 5.0, red); // still red inside: nothing to emit
|
||||
p.fill_rect(0.0, 0.0, 5.0, 5.0, blue);
|
||||
p.restore();
|
||||
p.fill_rect(0.0, 0.0, 5.0, 5.0, red); // red again after Q without a new operator
|
||||
p.fill_rect(0.0, 0.0, 5.0, 5.0, blue); // but blue must be re-emitted
|
||||
let fills: Vec<String> = interpret(&p.content).paints.iter().map(|p| p.fill.clone()).collect();
|
||||
assert_eq!(fills, ["1 0 0", "1 0 0", "0 0 1", "1 0 0", "0 0 1"]);
|
||||
assert_eq!(p.content.matches(" rg\n").count(), 3, "red once, blue twice");
|
||||
let p2 = d.add_page(100.0, 100.0);
|
||||
p2.fill_rect(0.0, 0.0, 5.0, 5.0, red);
|
||||
assert_eq!(p2.content.matches(" rg\n").count(), 1, "a new page re-emits the colour");
|
||||
}
|
||||
|
||||
/// B6: only 28 nested saves are written, the matching extra restores are no-ops.
|
||||
#[test]
|
||||
fn save_nesting_is_limited_to_28() {
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(100.0, 100.0);
|
||||
for _ in 0..40 {
|
||||
p.save();
|
||||
}
|
||||
p.fill_rect(0.0, 0.0, 5.0, 5.0, [1, 1, 1]);
|
||||
assert_eq!(p.content.matches("q\n").count(), 28);
|
||||
// The 12 restores that match refused saves do nothing...
|
||||
for _ in 0..12 {
|
||||
p.restore();
|
||||
}
|
||||
assert_eq!(p.content.matches("Q\n").count(), 0);
|
||||
assert_eq!(interpret(&p.content).max_depth, 28);
|
||||
// ...the next 28 undo the accepted ones, and further restores are ignored.
|
||||
for _ in 0..40 {
|
||||
p.restore();
|
||||
}
|
||||
assert_eq!(p.content.matches("Q\n").count(), 28);
|
||||
let i = interpret(&p.content);
|
||||
assert!(i.balanced);
|
||||
// An image needs its own q: dropped at the limit, drawn otherwise.
|
||||
for _ in 0..28 {
|
||||
p.save();
|
||||
}
|
||||
p.image_rgba(0.0, 0.0, 5.0, 5.0, &[9; 16], 2, 2);
|
||||
assert!(!p.content.contains(" Do"));
|
||||
p.restore();
|
||||
p.image_rgba(0.0, 0.0, 5.0, 5.0, &[9; 16], 2, 2);
|
||||
assert!(p.content.contains(" Do"));
|
||||
let pdf = d.finish();
|
||||
check_xref(&pdf);
|
||||
}
|
||||
|
||||
/// B6: `finish()` closes what is still open, without exceeding the limit.
|
||||
#[test]
|
||||
fn finish_closes_open_saves() {
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(100.0, 100.0);
|
||||
for _ in 0..33 {
|
||||
p.save();
|
||||
}
|
||||
let (_, pages) = parse_pages(&d.finish());
|
||||
let i = interpret(&pages[0].content);
|
||||
assert!(i.balanced);
|
||||
assert_eq!(i.qs, 28);
|
||||
}
|
||||
|
||||
/// B7: round joins set for a polyline do not leak into later rectangles, lines and circles.
|
||||
#[test]
|
||||
fn join_does_not_leak_from_polylines() {
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(200.0, 200.0);
|
||||
let pts = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]];
|
||||
p.rect_stroke(0.0, 0.0, 5.0, 5.0, 1.0, [0, 0, 0]);
|
||||
p.polyline(&pts, 1.0, [0, 0, 0], &[]);
|
||||
p.polyline(&pts, 1.0, [0, 0, 0], &[]);
|
||||
p.rect_stroke(0.0, 0.0, 5.0, 5.0, 1.0, [0, 0, 0]);
|
||||
p.stroke_line(0.0, 0.0, 9.0, 9.0, 1.0, [0, 0, 0]);
|
||||
p.polygon(&pts, None, Some(([0, 0, 0], 1.0)));
|
||||
p.circle(50.0, 50.0, 5.0, None, Some(([0, 0, 0], 1.0)));
|
||||
let joins: Vec<String> = interpret(&p.content).paints.iter().map(|p| p.join.clone()).collect();
|
||||
assert_eq!(joins, ["0", "1", "1", "0", "0", "1", "0"]);
|
||||
assert_eq!(p.content.matches(" j\n").count(), 4, "1, 0, 1, 0: only on change");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
//! The content-stream emission of the writer before the cleanup (a verbatim copy of the old
|
||||
//! per-operator formatting, which re-emitted the full style for every operator, had no `q` limit
|
||||
//! and left `1 j` set after polylines), plus an interpreter that reduces a content stream to the
|
||||
//! effective graphics state of every painting operator.
|
||||
|
||||
use super::super::*;
|
||||
|
||||
fn old_rgb_op(c: Rgb, stroke: bool) -> String {
|
||||
let f = |v: u8| n(v as f32 / 255.0);
|
||||
format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" })
|
||||
}
|
||||
|
||||
/// The old content writer.
|
||||
#[derive(Default)]
|
||||
pub struct OldPage {
|
||||
pub height: f32,
|
||||
pub content: String,
|
||||
depth: u32,
|
||||
images: usize,
|
||||
}
|
||||
|
||||
impl OldPage {
|
||||
pub fn new(height: f32) -> OldPage {
|
||||
OldPage { height, ..Default::default() }
|
||||
}
|
||||
fn y(&self, y: f32) -> f32 {
|
||||
self.height - y
|
||||
}
|
||||
fn op(&mut self, s: &str) {
|
||||
self.content.push_str(s);
|
||||
self.content.push('\n');
|
||||
}
|
||||
pub fn save(&mut self) {
|
||||
self.depth += 1;
|
||||
self.content.push_str("q\n");
|
||||
}
|
||||
pub fn restore(&mut self) {
|
||||
if self.depth > 0 {
|
||||
self.depth -= 1;
|
||||
self.content.push_str("Q\n");
|
||||
}
|
||||
}
|
||||
pub fn clip_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
|
||||
let s = format!("{} {} {} {} re W n", n(x), n(self.y(y + h)), n(w.max(0.0)), n(h.max(0.0)));
|
||||
self.op(&s);
|
||||
}
|
||||
pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) {
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let s = format!("{}\n{} {} {} {} re f", old_rgb_op(color, false), n(x), n(self.y(y + h)), n(w), n(h));
|
||||
self.op(&s);
|
||||
}
|
||||
pub fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, width: f32, color: Rgb) {
|
||||
let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", old_rgb_op(color, true), n(width), n(x), n(self.y(y + h)), n(w), n(h));
|
||||
self.op(&s);
|
||||
}
|
||||
pub fn stroke_line_dashed(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb, dash: &[f32]) {
|
||||
let d: Vec<String> = dash.iter().take(8).map(|v| n(v.max(0.0))).collect();
|
||||
let s = format!(
|
||||
"{}\n{} w [{}] 0 d\n{} {} m {} {} l S",
|
||||
old_rgb_op(color, true),
|
||||
n(width),
|
||||
d.join(" "),
|
||||
n(x0),
|
||||
n(self.y(y0)),
|
||||
n(x1),
|
||||
n(self.y(y1))
|
||||
);
|
||||
self.op(&s);
|
||||
}
|
||||
pub fn polyline(&mut self, pts: &[[f32; 2]], width: f32, color: Rgb, dash: &[f32]) {
|
||||
if pts.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let d: Vec<String> = dash.iter().take(8).map(|v| n(v.max(0.0))).collect();
|
||||
let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", old_rgb_op(color, true), n(width), d.join(" "));
|
||||
self.path(&mut s, pts, false);
|
||||
s.push_str(" S");
|
||||
self.op(&s);
|
||||
}
|
||||
fn path(&self, s: &mut String, pts: &[[f32; 2]], close: bool) {
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
let _ = write!(s, "{} {} {} ", n(p[0]), n(self.y(p[1])), if i == 0 { "m" } else { "l" });
|
||||
}
|
||||
if close {
|
||||
s.push('h');
|
||||
}
|
||||
}
|
||||
pub fn polygon(&mut self, pts: &[[f32; 2]], fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
|
||||
if pts.len() < 2 || (fill.is_none() && stroke.is_none()) {
|
||||
return;
|
||||
}
|
||||
let mut s = String::new();
|
||||
if let Some(f) = fill {
|
||||
s.push_str(&old_rgb_op(f, false));
|
||||
s.push('\n');
|
||||
}
|
||||
if let Some((c, w)) = stroke {
|
||||
let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", old_rgb_op(c, true), n(w));
|
||||
}
|
||||
self.path(&mut s, pts, true);
|
||||
s.push_str(match (fill.is_some(), stroke.is_some()) {
|
||||
(true, true) => " B",
|
||||
(true, false) => " f",
|
||||
_ => " S",
|
||||
});
|
||||
self.op(&s);
|
||||
}
|
||||
pub fn circle(&mut self, cx: f32, cy: f32, r: f32, fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
|
||||
if r <= 0.0 || (fill.is_none() && stroke.is_none()) {
|
||||
return;
|
||||
}
|
||||
let y = self.y(cy);
|
||||
let mut s = String::new();
|
||||
if let Some(f) = fill {
|
||||
s.push_str(&old_rgb_op(f, false));
|
||||
s.push('\n');
|
||||
}
|
||||
if let Some((c, w)) = stroke {
|
||||
let _ = writeln!(s, "{}\n{} w [] 0 d", old_rgb_op(c, true), n(w));
|
||||
}
|
||||
const K: f32 = 0.552_284_8;
|
||||
let k = r * K;
|
||||
let _ = write!(
|
||||
s,
|
||||
"{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c h",
|
||||
n(cx + r),
|
||||
n(y),
|
||||
n(cx + r),
|
||||
n(y + k),
|
||||
n(cx + k),
|
||||
n(y + r),
|
||||
n(cx),
|
||||
n(y + r),
|
||||
n(cx - k),
|
||||
n(y + r),
|
||||
n(cx - r),
|
||||
n(y + k),
|
||||
n(cx - r),
|
||||
n(y),
|
||||
n(cx - r),
|
||||
n(y - k),
|
||||
n(cx - k),
|
||||
n(y - r),
|
||||
n(cx),
|
||||
n(y - r),
|
||||
n(cx + k),
|
||||
n(y - r),
|
||||
n(cx + r),
|
||||
n(y - k),
|
||||
n(cx + r),
|
||||
n(y)
|
||||
);
|
||||
s.push_str(match (fill.is_some(), stroke.is_some()) {
|
||||
(true, true) => " B",
|
||||
(true, false) => " f",
|
||||
_ => " S",
|
||||
});
|
||||
self.op(&s);
|
||||
}
|
||||
pub fn text_rotated(&mut self, x: f32, y: f32, size: f32, font: Font, color: Rgb, text: &str, angle: f32) {
|
||||
let fi = font.index();
|
||||
let (sn, cs) = angle.sin_cos();
|
||||
let s = format!(
|
||||
"BT\n{}\n/F{} {} Tf\n{} {} {} {} {} {} Tm\n{} Tj\nET",
|
||||
old_rgb_op(color, false),
|
||||
fi + 1,
|
||||
n(size),
|
||||
n(cs),
|
||||
n(-sn),
|
||||
n(sn),
|
||||
n(cs),
|
||||
n(x),
|
||||
n(self.y(y)),
|
||||
pdf_string(text)
|
||||
);
|
||||
self.op(&s);
|
||||
}
|
||||
pub fn image(&mut self, x: f32, y: f32, w: f32, h: f32) {
|
||||
self.images += 1;
|
||||
let s = format!("q {} 0 0 {} {} {} cm /Im{} Do Q", n(w), n(h), n(x), n(self.y(y + h)), self.images);
|
||||
self.op(&s);
|
||||
}
|
||||
}
|
||||
|
||||
/// The effective state at one painting operator. Fields that cannot influence the operator
|
||||
/// (the stroke state of a fill, the fill of a stroke) are left empty.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct Paint {
|
||||
pub op: String,
|
||||
/// Path construction / text operands since the previous painting operator.
|
||||
pub geometry: String,
|
||||
pub fill: String,
|
||||
pub stroke: String,
|
||||
pub width: String,
|
||||
pub dash: String,
|
||||
pub join: String,
|
||||
/// Clip paths in effect (innermost last).
|
||||
pub clip: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Interp {
|
||||
pub paints: Vec<Paint>,
|
||||
pub max_depth: usize,
|
||||
/// Number of `q` operators.
|
||||
pub qs: usize,
|
||||
/// Number of colour/width/dash/join operators.
|
||||
pub style_ops: usize,
|
||||
pub balanced: bool,
|
||||
}
|
||||
|
||||
fn tokens(content: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut chars = content.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'(' => {
|
||||
if !cur.is_empty() {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
let mut s = String::from("(");
|
||||
while let Some(d) = chars.next() {
|
||||
s.push(d);
|
||||
if d == '\\' {
|
||||
if let Some(e) = chars.next() {
|
||||
s.push(e);
|
||||
}
|
||||
} else if d == ')' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push(s);
|
||||
}
|
||||
'[' | ']' => {
|
||||
if !cur.is_empty() {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
out.push(c.to_string());
|
||||
}
|
||||
c if c.is_whitespace() => {
|
||||
if !cur.is_empty() {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
}
|
||||
c => cur.push(c),
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct State {
|
||||
fill: String,
|
||||
stroke: String,
|
||||
width: String,
|
||||
dash: String,
|
||||
join: String,
|
||||
clip: Vec<String>,
|
||||
}
|
||||
|
||||
/// Interprets a content stream: tracks q/Q, colours, width, dash, join and clips.
|
||||
pub fn interpret(content: &str) -> Interp {
|
||||
let mut st = State { fill: "0 0 0".into(), stroke: "0 0 0".into(), width: "1".into(), dash: String::new(), join: "0".into(), clip: Vec::new() };
|
||||
let mut stack: Vec<State> = Vec::new();
|
||||
let mut r = Interp { balanced: true, ..Default::default() };
|
||||
let mut operands: Vec<String> = Vec::new();
|
||||
let mut path: Vec<String> = Vec::new();
|
||||
let mut pending_clip = false;
|
||||
for t in tokens(content) {
|
||||
let is_op = t.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '*') && !t.starts_with('/');
|
||||
if !is_op {
|
||||
operands.push(t);
|
||||
continue;
|
||||
}
|
||||
let ops = std::mem::take(&mut operands);
|
||||
match t.as_str() {
|
||||
"q" => {
|
||||
stack.push(st.clone());
|
||||
r.qs += 1;
|
||||
r.max_depth = r.max_depth.max(stack.len());
|
||||
}
|
||||
"Q" => match stack.pop() {
|
||||
Some(s) => st = s,
|
||||
None => r.balanced = false,
|
||||
},
|
||||
"rg" => {
|
||||
st.fill = ops.join(" ");
|
||||
r.style_ops += 1;
|
||||
}
|
||||
"RG" => {
|
||||
st.stroke = ops.join(" ");
|
||||
r.style_ops += 1;
|
||||
}
|
||||
"w" => {
|
||||
st.width = ops.join(" ");
|
||||
r.style_ops += 1;
|
||||
}
|
||||
"d" => {
|
||||
// `[a b] 0` -> `a b`
|
||||
st.dash = ops.iter().take_while(|o| *o != "]").skip(1).cloned().collect::<Vec<_>>().join(" ");
|
||||
r.style_ops += 1;
|
||||
}
|
||||
"j" => {
|
||||
st.join = ops.join(" ");
|
||||
r.style_ops += 1;
|
||||
}
|
||||
"re" | "m" | "l" | "c" | "h" | "cm" | "Tf" | "Tm" => {
|
||||
path.push(format!("{} {}", ops.join(" "), t));
|
||||
}
|
||||
"BT" | "ET" => {}
|
||||
"W" => pending_clip = true,
|
||||
"n" => {
|
||||
if pending_clip {
|
||||
st.clip.push(path.join(" "));
|
||||
pending_clip = false;
|
||||
}
|
||||
path.clear();
|
||||
}
|
||||
"Tj" => {
|
||||
path.push(format!("{} {}", ops.join(" "), t));
|
||||
r.paints.push(Paint { op: t.clone(), geometry: path.join(" "), fill: st.fill.clone(), clip: st.clip.clone(), ..Default::default() });
|
||||
path.clear();
|
||||
}
|
||||
"f" | "S" | "B" | "Do" => {
|
||||
let mut p = Paint { op: t.clone(), geometry: path.join(" "), clip: st.clip.clone(), ..Default::default() };
|
||||
if t == "f" || t == "B" {
|
||||
p.fill = st.fill.clone();
|
||||
}
|
||||
if t == "S" || t == "B" {
|
||||
p.stroke = st.stroke.clone();
|
||||
p.width = st.width.clone();
|
||||
p.dash = st.dash.clone();
|
||||
p.join = st.join.clone();
|
||||
}
|
||||
r.paints.push(p);
|
||||
path.clear();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
r.balanced &= stack.is_empty();
|
||||
r
|
||||
}
|
||||
Reference in New Issue
Block a user