M13.12: pdf /W2 and /DW2 vertical metrics, CMap WMode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 08:48:25 -04:00
parent d15b69c734
commit 3d536cfe5a
3 changed files with 116 additions and 2 deletions

View File

@ -57,6 +57,10 @@ pub(crate) struct Font {
codespace: Vec<(usize, u32, u32)>,
cid_ranges: Vec<(u32, u32, u32)>,
pub vertical: bool,
/// Vertical metrics (`/W2`, in text space): CID → (w1y, vx, vy).
cid_vmetrics: HashMap<u32, (f64, f64, f64)>,
/// `/DW2`: default (vy, w1y).
dw2: (f64, f64),
cache: RefCell<HashMap<u32, Option<Arc<BezPath>>>>,
/// Program glyph advances (font units → text space) for fonts without `/Widths`.
has_widths: bool,
@ -194,6 +198,8 @@ struct CMap {
codespace: Vec<(usize, u32, u32)>,
unicode: HashMap<u32, String>,
cids: Vec<(u32, u32, u32)>,
/// `/WMode 1`: vertical writing.
wmode: bool,
}
fn be(b: &[u8]) -> u32 {
@ -285,6 +291,14 @@ fn parse_cmap(data: &[u8]) -> CMap {
}
mode = "";
}
"def" if mode.is_empty() => {
if let [.., Obj::Name(k), Obj::Num(v)] = args.as_slice()
&& k == "WMode"
{
m.wmode = *v == 1.0;
}
args.clear();
}
_ => {
if mode.is_empty() {
args.clear();
@ -315,6 +329,8 @@ impl Font {
codespace: vec![],
cid_ranges: vec![],
vertical: false,
cid_vmetrics: HashMap::new(),
dw2: (0.88, -1.0),
cache: RefCell::new(HashMap::new()),
has_widths: false,
base_name: base_name.clone(),
@ -366,6 +382,37 @@ impl Font {
}
}
}
// Vertical metrics (§9.7.4.3): /DW2 [vy w1y] and /W2 entries
// `c [w1y vx vy …]` or `cfirst clast w1y vx vy`.
let dw2 = file.get(&desc_dict, "DW2").map(|x| file.nums(x)).unwrap_or_default();
if dw2.len() == 2 {
f.dw2 = (dw2[0] / 1000.0, dw2[1] / 1000.0);
}
if let Some(w) = file.get(&desc_dict, "W2").and_then(Obj::array) {
let num = |i: usize| w.get(i).and_then(|x| file.resolve(x).num());
let mut i = 0;
while i < w.len() {
let Some(first) = num(i) else { break };
let first = first.max(0.0) as u32;
match w.get(i + 1).map(|x| file.resolve(x)) {
Some(Obj::Array(list)) => {
let v: Vec<f64> = list.iter().filter_map(|x| file.resolve(x).num()).collect();
for (k, t) in v.chunks_exact(3).enumerate() {
f.cid_vmetrics.insert(first + k as u32, (t[0] / 1000.0, t[1] / 1000.0, t[2] / 1000.0));
}
i += 2;
}
Some(Obj::Num(last)) => {
let (Some(w1), Some(vx), Some(vy)) = (num(i + 2), num(i + 3), num(i + 4)) else { break };
for c in first..=(last.max(0.0) as u32).min(first + 65535) {
f.cid_vmetrics.insert(c, (w1 / 1000.0, vx / 1000.0, vy / 1000.0));
}
i += 5;
}
_ => break,
}
}
}
f.has_widths = true;
// Encoding CMap.
match file.get(d, "Encoding") {
@ -378,6 +425,7 @@ impl Font {
Some(Obj::Stream(sd, raw)) => {
if let Some(data) = decode_stream(file, sd, raw) {
let cm = parse_cmap(&data);
f.vertical = cm.wmode || file.get_num(sd, "WMode") == Some(1.0);
f.codespace = cm.codespace;
f.cid_ranges = cm.cids;
}
@ -530,6 +578,17 @@ impl Font {
self.cid_ranges.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map(|(lo, _, c)| c + (code - lo)).unwrap_or(0)
}
/// Vertical metrics of a code in text space (before the font size): the vertical
/// displacement `w1y` (negative: downwards) and the position vector `(vx, vy)` from the
/// horizontal origin to the vertical origin (§9.7.4.3; defaults from `/DW2`, `vx` half the
/// horizontal advance).
pub fn vmetrics(&self, code: u32) -> (f64, f64, f64) {
match self.cid_vmetrics.get(&self.cid(code)) {
Some(m) => *m,
None => (self.dw2.1, self.width(code) / 2.0, self.dw2.0),
}
}
/// The advance of a code in text space (before the font size).
pub fn width(&self, code: u32) -> f64 {
if self.composite {

View File

@ -1047,7 +1047,8 @@ impl<'a> Interp<'a> {
for (code, nbytes) in font.codes(s) {
let w0 = font.width(code);
let trm = self.tm * Affine::new([fs * th, 0.0, 0.0, fs, 0.0, rise]);
let trm = if font.vertical { trm * Affine::translate((-w0 / 2.0, -0.88)) } else { trm };
let (w1y, vx, vy) = if font.vertical { font.vmetrics(code) } else { (0.0, 0.0, 0.0) };
let trm = if font.vertical { trm * Affine::translate((-vx, -vy)) } else { trm };
if let Some((proc_, t3res)) = font.type3_proc(code) {
let proc_ = proc_.to_vec();
let r = t3res.cloned().unwrap_or_else(|| res.clone());
@ -1060,7 +1061,7 @@ impl<'a> Interp<'a> {
text.push_str(&font.unicode(code));
let tw = if nbytes == 1 && code == 32 { self.gs.word_spacing } else { 0.0 };
if font.vertical {
self.tm *= Affine::translate((0.0, -(fs + self.gs.char_spacing + tw)));
self.tm *= Affine::translate((0.0, w1y * fs - self.gs.char_spacing - tw));
} else {
self.tm *= Affine::translate(((w0 * fs + self.gs.char_spacing + tw) * th, 0.0));
}

View File

@ -97,3 +97,57 @@ fn soft_mask_applies_to_the_masked_group_result() {
let p = img.get(75, 50);
assert!((p[3] - 0.5).abs() < 0.02 && p[0] < 0.02 && (p[2] - 0.5).abs() < 0.02, "{p:?}");
}
/// The ink bounding box `(x0, y0, x1, y1)` of a render (pixels with alpha > 0.5).
fn ink_box(img: &effectcraft_raster::Image) -> (i64, i64, i64, i64) {
let (mut x0, mut y0, mut x1, mut y1) = (i64::MAX, i64::MAX, i64::MIN, i64::MIN);
for y in 0..img.height as i64 {
for x in 0..img.width as i64 {
if img.get(x, y)[3] > 0.5 {
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
}
}
(x0, y0, x1, y1)
}
#[test]
fn vertical_metrics_w2_and_dw2() {
use skrifa::MetadataProvider;
let inter = effectcraft_text::fonts::INTER_REGULAR.to_vec();
let font = skrifa::FontRef::new(&inter).unwrap();
let h = font.charmap().map('H').unwrap().to_u32();
let doc = |desc_extra: &str| {
let bytes = page_pdf(
&format!("BT /F1 20 Tf 100 95 Td <{h:04X}{h:04X}> Tj ET"),
"/Font << /F1 10 0 R >>",
vec![
(10, "<< /Type /Font /Subtype /Type0 /BaseFont /Inter /Encoding /Identity-V /DescendantFonts [13 0 R] >>".into(), None),
(
13,
format!(
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Inter /CIDToGIDMap /Identity /W [{h} [700]] {desc_extra} /FontDescriptor 11 0 R >>"
),
None,
),
(11, "<< /Type /FontDescriptor /Flags 32 /FontFile2 12 0 R >>".into(), None),
(12, "<< >>".into(), Some(inter.clone())),
],
);
let d = parse(&bytes).unwrap();
assert!(d.skipped.is_empty(), "{:?}", d.skipped);
ink_box(&render(&d))
};
// Defaults: an advance of 1 em down; the vertical origin (350, 880) from the horizontal one.
let base = doc("");
// /W2: a 2 em advance and vx 250.
let w2 = doc(&format!("/W2 [{h} [-2000 250 880]]"));
assert_eq!(w2.1, base.1, "the first glyph's top stays: {base:?} {w2:?}");
assert!(((w2.3 - base.3) - 20).abs() <= 1, "second glyph 20 pt lower: {base:?} {w2:?}");
assert!(((w2.0 - base.0) - 2).abs() <= 1, "vx 250 vs 350 → 2 pt right: {base:?} {w2:?}");
// The range form gives the same; /DW2 with vy 780 draws the glyphs 2 pt higher.
let range = doc(&format!("/W2 [{h} {h} -2000 250 880]"));
assert_eq!(range, w2);
let dw2 = doc("/DW2 [780 -1000]");
assert!(((base.1 - dw2.1) - 2).abs() <= 1, "vy 780 → 2 pt higher: {base:?} {dw2:?}");
}