mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
M2: a stand-in for a non-embedded font keeps its glyph shapes at wider /Widths advances
A simple font that a document names but doesn't embed (Arial,Bold, ArialMT, ...) is drawn with a standard-font stand-in, and each glyph was scaled horizontally to its /Widths advance, narrower or wider. With 600-unit widths, Helvetica-Bold's i and l (278 units) were widened 2.16 times into solid blocks, and the line read as a heavy fixed-pitch face. The advance already places the next glyph (ISO 32000-2 §9.2.4; Widths, Table 109), so a substituted glyph is still narrowed to a narrower advance but no longer widened. Exact standard-14 fonts are unchanged. Vendored hayro-interpret patch 57 (StandardKind::outline_glyph), with tests in crates/render/tests/it/substitute_widths.rs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0b670caf4a
commit
4287340c04
@@ -4,3 +4,4 @@ mod inline_image_ei;
|
||||
mod jbig2_invariants;
|
||||
mod named_form_properties;
|
||||
mod search;
|
||||
mod substitute_widths;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Contributor-original synthetic fonts: a simple font the document names but doesn't embed is
|
||||
//! drawn with a standard-font stand-in, its glyphs placed at the advances `/Widths` gives
|
||||
//! (ISO 32000-2 §9.6.2.1, Table 109) and their shapes kept.
|
||||
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest};
|
||||
use std::sync::Arc;
|
||||
|
||||
const SIZE: f64 = 80.0;
|
||||
|
||||
fn pdf(objects: &[String]) -> Vec<u8> {
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// A page showing `text` at 80 pt in the font dictionary `font`, with the font descriptor
|
||||
/// `descriptor` as object 6.
|
||||
fn page(font: &str, descriptor: &str, text: &str) -> Vec<u8> {
|
||||
let body = format!("BT /F1 {SIZE} Tf 20 30 Td ({text}) Tj ET");
|
||||
pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 140] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
|
||||
font.into(),
|
||||
descriptor.into(),
|
||||
])
|
||||
}
|
||||
|
||||
/// The runs of columns holding dark pixels, as (first, last) column.
|
||||
fn ink_runs(bytes: Vec<u8>) -> Vec<(u32, u32)> {
|
||||
let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
|
||||
let image = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(image.error.is_none(), "{:?}", image.error);
|
||||
let dark = |x: u32| {
|
||||
(0..image.height).any(|y| {
|
||||
let at = usize::try_from((y * image.width + x) * 4).unwrap_or(usize::MAX);
|
||||
image.rgba.get(at).is_some_and(|r| *r < 128)
|
||||
})
|
||||
};
|
||||
let mut runs: Vec<(u32, u32)> = Vec::new();
|
||||
for x in 0..image.width {
|
||||
if dark(x) {
|
||||
match runs.last_mut() {
|
||||
Some(run) if run.1 + 1 == x => run.1 = x,
|
||||
_ => runs.push((x, x)),
|
||||
}
|
||||
}
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
fn standard(name: &str, text: &str) -> Vec<(u32, u32)> {
|
||||
ink_runs(page(&format!("<< /Type /Font /Subtype /Type1 /BaseFont /{name} /Encoding /WinAnsiEncoding >>"), "<< >>", text))
|
||||
}
|
||||
|
||||
fn substituted(base_font: &str, flags: u32, weight: u32, code: u8, width: u32, text: &str) -> Vec<(u32, u32)> {
|
||||
let font = format!(
|
||||
"<< /Type /Font /Subtype /TrueType /BaseFont /{base_font} /Encoding /WinAnsiEncoding \
|
||||
/FirstChar {code} /LastChar {code} /Widths [{width}] /FontDescriptor 6 0 R >>"
|
||||
);
|
||||
let descriptor = format!(
|
||||
"<< /Type /FontDescriptor /FontName /{base_font} /Flags {flags} /FontBBox [-600 -200 2000 1000] \
|
||||
/ItalicAngle 0 /Ascent 900 /Descent -200 /CapHeight 700 /StemV 140 /FontWeight {weight} >>"
|
||||
);
|
||||
ink_runs(page(&font, &descriptor, text))
|
||||
}
|
||||
|
||||
/// A `/Widths` entry wider than the stand-in's glyph moves the next glyph on, and the glyph keeps
|
||||
/// its own shape: every advance of 600 stretched each glyph of `Arial,Bold` to 600 units, so the
|
||||
/// stems of `i` and `l` became solid blocks twice their width and the text read as a heavy
|
||||
/// fixed-pitch face.
|
||||
#[test]
|
||||
fn a_substitute_keeps_its_glyph_shapes_at_wider_advances() {
|
||||
for (base_font, flags, weight, stand_in) in [
|
||||
("Arial,Bold", 32, 700, "Helvetica-Bold"),
|
||||
("Arial,Bold", 4, 700, "Helvetica-Bold"),
|
||||
("Arial-BoldMT", 32, 700, "Helvetica-Bold"),
|
||||
("ArialMT", 32, 400, "Helvetica"),
|
||||
] {
|
||||
let reference = standard(stand_in, "l");
|
||||
let [(ref_start, ref_end)] = reference[..] else { panic!("{stand_in}: one stem, {reference:?}") };
|
||||
let runs = substituted(base_font, flags, weight, b'l', 600, "ll");
|
||||
let [(first, first_end), (second, second_end)] = runs[..] else { panic!("{base_font}: two stems, {runs:?}") };
|
||||
for (start, end) in [(first, first_end), (second, second_end)] {
|
||||
assert!(
|
||||
(end - start).abs_diff(ref_end - ref_start) <= 1,
|
||||
"{base_font} /Flags {flags}: the stem is {stand_in}'s, not stretched: {runs:?} vs {reference:?}"
|
||||
);
|
||||
}
|
||||
// 600 units at 80 pt is 48 pixels.
|
||||
assert!((second - first).abs_diff(48) <= 1, "{base_font}: the second l is 600 units on: {runs:?}");
|
||||
assert!(first.abs_diff(ref_start) <= 1, "{base_font}: the first l starts where {stand_in}'s does: {runs:?} vs {reference:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A `/Widths` entry narrower than the stand-in's glyph still narrows the glyph to it, so
|
||||
/// neighbours don't overlap: `W` (944 units in Helvetica-Bold) at an advance of 400.
|
||||
#[test]
|
||||
fn a_substitute_still_narrows_glyphs_to_narrower_advances() {
|
||||
let runs = substituted("Arial,Bold", 32, 700, b'W', 400, "WW");
|
||||
let [(first, first_end), (second, _)] = runs[..] else { panic!("two separate glyphs: {runs:?}") };
|
||||
// 400 units at 80 pt is 32 pixels.
|
||||
assert!(first_end - first <= 33, "the W is narrowed to its advance: {runs:?}");
|
||||
assert!((second - first).abs_diff(32) <= 1, "the second W is 400 units on: {runs:?}");
|
||||
}
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -544,6 +544,15 @@ impl StandardKind {
|
||||
return path;
|
||||
};
|
||||
|
||||
// PdfCraft patch (57): a stand-in for a font the document names is narrowed to a
|
||||
// narrower /Widths advance, so neighbours don't overlap, but never widened: the
|
||||
// advance already places the next glyph, and widening drew `i` and `l` of a 600-unit
|
||||
// `Arial,Bold` as solid blocks twice their width.
|
||||
let should_width = if self.fallback {
|
||||
should_width.min(actual_width)
|
||||
} else {
|
||||
should_width
|
||||
};
|
||||
return stretch_glyph(path, should_width, actual_width);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user