Files
pdfcraft/crates/edit/src/rules.rs
Grzegorz MartensandClaude Opus 5.5 8eead8ecdd Merge main: the font reads main added are paid for from the allowance too
main's Thai work (#6249, #3492) and the Type 3 and symbolic TrueType
metrics changed the code this PR bounds. Each conflict, and each read main
added that this PR's allowance must cover:

- crates/fonts/src/pdf.rs, the editor's program read: this PR's read is
  kept, its stage-scaled limit and its check for an allowance already
  spent; main's read had /DecodeParms by reference too, which it has.
- The editor's loop: main rebuilds a cmap for a composite subset without
  one from its ToUnicode and /CIDToGIDMap, where this PR skipped Type 0.
  main's composite support stays, with the metrics read within the
  editor's allowance (`from_dict_within`) and no copy adapted from metrics
  read past it, as for a simple font. The /CIDToGIDMap the cmap is rebuilt
  from (`composite_glyph_maps`) reads as the font's other streams do
  (`Reads::decode`): within the allowance, behind several filters too.
- Reads main added to the metrics read: a Type 3 font's glyph
  descriptions (`type3_glyph_box`), and the program read that tells a
  symbolic TrueType font's codes as Mac OS Roman
  (`symbolic_mac_roman_texts`). Both are now paid for from the allowance
  the metrics are read within, and a refusal sets `past_allowance`. The
  glyph descriptions keep their own limits (64 KiB each, 4 MiB a font).
- Constants and helpers both sides added beside one another, and the
  tests both appended, are all kept. Callers main added of
  `unicode_map` pass a reading without a total, as `from_dict` does.
- crates/edit/src/rules.rs: the refusal of a page whose content didn't
  all decode stays, and the operators come from main's `text::joined`.

Tests: `adapting_a_programs_cmap_is_paid_for` now expects a composite
font's ToUnicode, and a simple font's program read again for the Mac OS
Roman reading, to be paid for. Four are new:
`adapting_a_composite_subsets_cmap_is_paid_for`,
`a_full_cid_map_rebuilds_a_composite_subsets_cmap_behind_two_filters`,
`type3_glyph_boxes_are_paid_for_from_the_allowance` and
`the_symbolic_mac_roman_reading_is_paid_for`. The fixture of main's test
of a composite subset without a cmap moves into `cmapless_composite`,
which both use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-09 12:00:17 +01:00

147 lines
6.2 KiB
Rust

//! A page's ruled lines: the horizontal and vertical strokes (and thin filled bars) its content
//! draws, so exports can tell a ruled table from text that merely lines up (#5149).
use pdfcraft_content::Matrix;
use pdfcraft_cos::Document;
use crate::EditError;
/// How deep `q` may nest (deeper saves are not tracked).
const MAX_SAVES: usize = 64;
/// At most this many rules are returned, and a path keeps at most this many segments, so a
/// hostile page stays bounded.
pub const MAX_RULES: usize = 4096;
/// A segment whose ends differ by at most this much across its run (points) is horizontal or
/// vertical; a filled rectangle at most this thick is a bar drawn as a rule.
const THIN: f64 = 2.0;
/// The page's ruled lines, each as its box `[x0, y0, x1, y1]` in default user space (`x0 <= x1`,
/// `y0 <= y1`; a stroke has zero thickness): horizontal and vertical straight segments of stroked
/// paths (rectangle edges included) and filled rectangles at most two points thick. Curves,
/// slanted lines and paths that are only clipped are left out, and so are the contents of forms.
/// At most [`MAX_RULES`] rules, in content order.
pub fn page_rules(doc: &Document, page: usize) -> Result<Vec<[f64; 4]>, EditError> {
let pages = pdfcraft_model::pages(doc);
let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?;
let streams = crate::text::content_streams(doc, &p.dict)?;
// Unread content can change the graphics state used by every later stream.
if streams.iter().any(|stream| stream.read != crate::text::Read::Whole) {
return Err(EditError::Invalid("page content could not be completely decoded; cannot extract its ruled lines safely".into()));
}
let pieces = crate::text::joined(&streams);
let ops = pieces.parse().ops;
let mut out: Vec<[f64; 4]> = Vec::new();
let mut ctm = Matrix::IDENTITY;
let mut saved: Vec<Matrix> = Vec::new();
let mut lost_saves = 0usize;
// The current path in page space: its straight segments, its rectangles, the current point
// and where the current subpath started.
let mut segments: Vec<[(f64, f64); 2]> = Vec::new();
let mut rects: Vec<[(f64, f64); 4]> = Vec::new();
let mut at: Option<(f64, f64)> = None;
let mut start: Option<(f64, f64)> = None;
for op in &ops {
if out.len() >= MAX_RULES {
break;
}
match op.op.as_slice() {
b"q" => {
if saved.len() < MAX_SAVES {
saved.push(ctm);
} else {
lost_saves = lost_saves.saturating_add(1);
}
}
b"Q" => {
if lost_saves > 0 {
lost_saves -= 1;
} else if let Some(m) = saved.pop() {
ctm = m;
}
}
b"cm" => {
if let Some(m) = Matrix::from_operands(&op.operands) {
ctm = m.then(&ctm);
}
}
b"m" => {
let p = op.nums::<2>().map(|[x, y]| ctm.apply(x, y));
(at, start) = (p, p);
}
b"l" => {
let p = op.nums::<2>().map(|[x, y]| ctm.apply(x, y));
if let (Some(a), Some(b)) = (at, p)
&& segments.len() < MAX_RULES
{
segments.push([a, b]);
}
at = p;
}
b"c" => at = op.nums::<6>().map(|[.., x, y]| ctm.apply(x, y)),
b"v" | b"y" => at = op.nums::<4>().map(|[.., x, y]| ctm.apply(x, y)),
b"h" => {
if let (Some(a), Some(b)) = (at, start)
&& segments.len() < MAX_RULES
{
segments.push([a, b]);
}
at = start;
}
b"re" => {
if let Some([x, y, w, h]) = op.nums::<4>() {
let q = [ctm.apply(x, y), ctm.apply(x + w, y), ctm.apply(x + w, y + h), ctm.apply(x, y + h)];
if rects.len() < MAX_RULES {
rects.push(q);
}
(at, start) = (Some(q[0]), Some(q[0]));
}
}
b"S" | b"s" | b"B" | b"B*" | b"b" | b"b*" => {
let closes = matches!(op.op.as_slice(), b"s" | b"b" | b"b*");
if closes && let (Some(a), Some(b)) = (at, start) {
segments.push([a, b]);
}
let edges = rects.iter().flat_map(|q| [[q[0], q[1]], [q[1], q[2]], [q[2], q[3]], [q[3], q[0]]]);
out.extend(segments.iter().copied().chain(edges).filter_map(axis_aligned));
(segments, rects, at, start) = (Vec::new(), Vec::new(), None, None);
}
b"f" | b"F" | b"f*" => {
out.extend(rects.iter().filter_map(thin_bar));
(segments, rects, at, start) = (Vec::new(), Vec::new(), None, None);
}
b"n" => (segments, rects, at, start) = (Vec::new(), Vec::new(), None, None),
_ => {}
}
}
out.truncate(MAX_RULES);
Ok(out)
}
/// The box of a horizontal or vertical segment, or `None` for a slanted, tiny or non-finite one.
fn axis_aligned([a, b]: [(f64, f64); 2]) -> Option<[f64; 4]> {
let r = [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)];
let (w, h) = (r[2] - r[0], r[3] - r[1]);
(r.iter().all(|v| v.is_finite()) && w.min(h) <= THIN && w.max(h) > THIN).then_some(r)
}
/// The box of a filled rectangle drawn as a rule: upright, at most [`THIN`] thick and longer than that.
fn thin_bar(q: &[(f64, f64); 4]) -> Option<[f64; 4]> {
let upright = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() <= 0.01 || (a.1 - b.1).abs() <= 0.01;
if !(upright(q[0], q[1]) && upright(q[1], q[2])) {
return None;
}
let xs = q.map(|p| p.0);
let ys = q.map(|p| p.1);
let r = [
xs.iter().copied().fold(f64::INFINITY, f64::min),
ys.iter().copied().fold(f64::INFINITY, f64::min),
xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
];
let (w, h) = (r[2] - r[0], r[3] - r[1]);
(r.iter().all(|v| v.is_finite()) && w.min(h) <= THIN && w.max(h) > THIN).then_some(r)
}