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:
vangel76
2026-10-12 22:28:19 +02:00
co-authored by Claude Sonnet 5.5
parent fc45b7f6d8
commit e04e559da3
4 changed files with 1184 additions and 103 deletions
+3 -1
View File
@@ -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
View File
@@ -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
}
}
+530
View File
@@ -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");
}
+349
View File
@@ -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
}