fix(render): draw Turkish Ğ İ Ş ğ ş in non-embedded fonts (vendored hayro-interpret patch 26) (#1409)

A PDF whose fonts aren't embedded is drawn with PDFium's Foxit stand-ins
for the standard 14 fonts. Those carry only the 230-odd glyphs of the
Standard, WinAnsi and Mac Roman encodings, so a letter a font names beyond
them in its /Differences mapped to .notdef and drew nothing. iText 2
writes Turkish exactly this way (Times New Roman left unembedded,
/Gbreve /Idotaccent /Scedilla /gbreve /scedilla over Ð Ý Þ ð þ), and such
letters showed blanks for Ğ, İ, Ş, ğ and ş while ı, ç, ö and ü drew.

StandardKind now builds a Latin Extended-A letter the stand-in lacks from
the base letter and accent it does have, as Type 1 seac composites and
the standard-14 AFM composites are built: the accent is centred on the
letter, raised over capitals and letters with ascenders (and moved along
the slant in italics), cedillas hang below and ogoneks from the right
edge. Composite glyph ids sit above any font's glyph count, one per
character code; their width comes from the standard metrics of the name.

Test: raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts
(fails without the patch: Gbreve drew no pixel).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TheLastRoadRunner 2026-10-12 14:57:03 +03:00 committed by GitHub
parent a4c53f706f
commit a259af95c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 267 additions and 10 deletions

View File

@ -4972,6 +4972,66 @@ trailer << /Root 1 0 R >>
assert!(px.rgba[((50 * px.width + 25) * 4) as usize] < 64, "the glyph is drawn");
}
/// Accented letters in non-embedded fonts (vendored hayro-interpret patch to
/// `StandardKind`): the stand-ins for the standard fonts hold only the glyphs of the
/// Standard, WinAnsi and Mac Roman encodings, so a letter a font names beyond those in its
/// /Differences drew nothing. iText 2 writes Turkish this way (`/Gbreve`, `/Idotaccent`,
/// `/Scedilla`, `/gbreve`, `/scedilla` over Ð, Ý, Þ, ð, þ), and a Turkish letter had blanks
/// for Ğ, İ, Ş, ğ and ş. Each is now its base letter with the accent above or below it.
#[test]
fn accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts() {
let letters = ["G", "Gbreve", "S", "Scedilla", "I", "Idotaccent", "g", "gbreve", "s", "scedilla"];
let mut content = String::new();
for (i, _) in letters.iter().enumerate() {
content.push_str(&format!("BT /F1 40 Tf {} 20 Td <{:02X}> Tj ET\n", 10 + 40 * i, 65 + i));
}
let differences = letters.iter().map(|n| format!("/{n}")).collect::<Vec<_>>().join(" ");
let pdf = format!(
"%PDF-1.4
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 420 80] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}endstream endobj
5 0 obj << /Type /Font /Subtype /TrueType /BaseFont /TimesNewRomanPSMT /FirstChar 65 /LastChar 74
/Widths [722 722 556 556 333 333 500 500 389 389] /FontDescriptor 6 0 R
/Encoding << /Type /Encoding /Differences [65 {differences}] >> >> endobj
6 0 obj << /Type /FontDescriptor /FontName /TimesNewRomanPSMT /Flags 32 /FontBBox [-568 -306 2045 1039]
/ItalicAngle 0 /Ascent 693 /Descent -215 /CapHeight 662 /StemV 80 >> endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{:?}", p.error);
// The dark pixels of the i-th letter: how many, and its top and bottom rows (y down).
let ink = |i: u32| {
let (mut count, mut top, mut bottom) = (0, u32::MAX, 0);
for y in 0..p.height {
for x in (8 + 40 * i)..(46 + 40 * i).min(p.width) {
if p.rgba[((y * p.width + x) * 4) as usize] < 128 {
count += 1;
top = top.min(y);
bottom = bottom.max(y);
}
}
}
(count, top, bottom)
};
for (pair, above) in [(0, true), (1, false), (2, true), (3, true), (4, false)] {
let (base, accented) = (ink(2 * pair), ink(2 * pair + 1));
let name = letters[2 * pair as usize + 1];
assert!(base.0 > 0, "{name}: the base letter draws");
assert!(accented.0 > base.0, "{name} draws its letter and accent: {accented:?} vs {base:?}");
if above {
assert!(accented.1 + 2 < base.1, "{name}: the accent sits above the letter: {accented:?} vs {base:?}");
} else {
assert!(accented.2 > base.2 + 2, "{name}: the accent hangs below the letter: {accented:?} vs {base:?}");
}
}
}
/// A page's `/UserUnit` (the size of its unit in points) scales the page and everything on
/// it, after the crop box and before `/Rotate`, as Acrobat shows such pages: with
/// `/UserUnit 2`, scale 1 renders pixel for pixel what scale 2 renders without it. As in

2
vendor/README.md vendored

File diff suppressed because one or more lines are too long

View File

@ -9,7 +9,7 @@ use crate::font::{
use hayro_syntax::object::Dict;
use hayro_syntax::object::Name;
use hayro_syntax::object::dict::keys::{BASE_FONT, FONT_DESC, FONT_WEIGHT, ITALIC_ANGLE};
use kurbo::BezPath;
use kurbo::{Affine, BezPath, Shape};
use skrifa::GlyphId;
use skrifa::raw::TableProvider;
use std::cell::RefCell;
@ -413,13 +413,18 @@ impl StandardKind {
let result = self
.code_to_ps_name(code)
.and_then(|c| {
self.base_font_blob.name_to_glyph(c).or_else(|| {
// If the font doesn't have a POST table, try to map via unicode instead.
glyph_names::get(c).and_then(|c| {
self.base_font_blob
.unicode_to_glyph(c.chars().nth(0).unwrap() as u32)
self.base_font_blob
.name_to_glyph(c)
.or_else(|| {
// If the font doesn't have a POST table, try to map via unicode instead.
glyph_names::get(c).and_then(|c| {
self.base_font_blob
.unicode_to_glyph(c.chars().nth(0).unwrap() as u32)
})
})
})
// PdfCraft patch: an accented letter the stand-in lacks is built from its
// parts (see `ACCENTED_LETTERS`).
.or_else(|| self.composite_glyph(code, c))
})
.unwrap_or(GlyphId::NOTDEF);
self.glyph_to_code.borrow_mut().insert(result, code);
@ -427,14 +432,79 @@ impl StandardKind {
result
}
/// PdfCraft patch: the glyph id standing for the letter `name` built from its base letter and
/// accent, when the stand-in has both. Ids from [`COMPOSITE_GLYPH_BASE`] up are one per
/// character code, so the outline is found again from the code alone.
fn composite_glyph(&self, code: u8, name: &str) -> Option<GlyphId> {
let (base, accent, _) = accented_parts(name)?;
self.base_font_blob.name_to_glyph(base)?;
self.base_font_blob.name_to_glyph(accent)?;
Some(GlyphId::new(COMPOSITE_GLYPH_BASE + u32::from(code)))
}
/// PdfCraft patch: the outline of a glyph from [`Self::composite_glyph`] — the base letter,
/// then the accent centred on it. An accent above a letter taller than `x` is raised by the
/// difference (the stand-in's accents sit on lowercase letters) and, in an italic font,
/// moved along the slant; an ogonek hangs from the letter's right edge.
fn composite_outline(&self, glyph: GlyphId) -> Option<BezPath> {
let code = u8::try_from(glyph.to_u32().checked_sub(COMPOSITE_GLYPH_BASE)?).ok()?;
let (base, accent, place) = accented_parts(self.code_to_ps_name(code)?)?;
let outline = |name: &str| {
self.base_font_blob
.name_to_glyph(name)
.map(|g| self.base_font_blob.outline_glyph(g))
};
let mut path = outline(base)?;
let accent = outline(accent)?;
if path.elements().is_empty() || accent.elements().is_empty() {
return Some(path);
}
let (b, a) = (path.bounding_box(), accent.bounding_box());
let (dx, dy) = match place {
Place::Below => (b.center().x - a.center().x, 0.0),
Place::Ogonek => (b.x1 - a.x1, 0.0),
Place::Above => {
let x_top = outline("x")
.filter(|x| !x.elements().is_empty())
.map_or(b.y1, |x| x.bounding_box().y1);
// Overshoot of round lowercase letters (o, g, s) over x is not a taller letter.
let raise = if b.y1 > x_top * 1.1 {
b.y1 - x_top
} else {
0.0
};
let slant = if self.base_font.is_italic() {
raise * ITALIC_ACCENT_SLANT
} else {
0.0
};
(b.center().x - a.center().x + slant, raise)
}
};
if !(dx.is_finite() && dy.is_finite()) {
return Some(path);
}
path.extend(Affine::translate((dx, dy)) * accent);
Some(path)
}
pub(crate) fn outline_glyph(&self, glyph: GlyphId) -> BezPath {
let path = self.base_font_blob.outline_glyph(glyph);
let composite = self.composite_outline(glyph);
let is_composite = composite.is_some();
let path = composite.unwrap_or_else(|| self.base_font_blob.outline_glyph(glyph));
// If the font is not embedded, we might need to stretch it so that
// it matches the metrics of the actual underlying font blob.
// PdfCraft patch: a composite id isn't a glyph of the blob, so its width comes from the
// standard metrics of its name.
let blob_width = if is_composite {
None
} else {
self.base_font_blob.advance_width(glyph)
};
if let Some(code) = self.glyph_to_code.borrow().get(&glyph).copied()
&& let Some(actual_width) = self.base_font_blob.advance_width(glyph).or_else(|| {
&& let Some(actual_width) = blob_width.or_else(|| {
self.code_to_ps_name(code)
.and_then(|name| self.base_font.get_width(name))
})
@ -495,3 +565,130 @@ impl StandardKind {
self.base_font.is_monospace()
}
}
/// PdfCraft patch: the first glyph id of the letters [`StandardKind`] builds from parts, far
/// above the glyph count of any font (one id per character code from here).
const COMPOSITE_GLYPH_BASE: u32 = 0x0100_0000;
/// PdfCraft patch: how far an accent raised over a capital moves right per unit of height in
/// an italic stand-in (tan 15°; the standard italics lean 12° to 16°).
const ITALIC_ACCENT_SLANT: f64 = 0.27;
/// Where an accent goes on its base letter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Place {
Above,
Below,
Ogonek,
}
/// PdfCraft patch: the stand-ins for the standard fonts (PDFium's Foxit fonts) carry only the
/// glyphs of the Standard, WinAnsi and Mac Roman encodings, so a font that isn't embedded and
/// names other Latin letters in its /Differences (`/Gbreve`, `/Idotaccent`, `/Scedilla`…, as
/// iText 2 writes Turkish text with Times New Roman left unembedded) drew nothing for them. Each of
/// these Latin Extended-A letters is a base letter the stand-ins have plus one of their accents,
/// as Type 1 `seac` composites and the composites of the standard-14 AFM files are built.
/// Letters whose accent isn't a plain one (ď ľ ť and the comma accents) aren't here.
const ACCENTED_LETTERS: &[(&str, &str, &str, Place)] = &[
("Amacron", "A", "macron", Place::Above),
("amacron", "a", "macron", Place::Above),
("Abreve", "A", "breve", Place::Above),
("abreve", "a", "breve", Place::Above),
("Aogonek", "A", "ogonek", Place::Ogonek),
("aogonek", "a", "ogonek", Place::Ogonek),
("Cacute", "C", "acute", Place::Above),
("cacute", "c", "acute", Place::Above),
("Ccircumflex", "C", "circumflex", Place::Above),
("ccircumflex", "c", "circumflex", Place::Above),
("Cdotaccent", "C", "dotaccent", Place::Above),
("cdotaccent", "c", "dotaccent", Place::Above),
("Ccaron", "C", "caron", Place::Above),
("ccaron", "c", "caron", Place::Above),
("Dcaron", "D", "caron", Place::Above),
("Emacron", "E", "macron", Place::Above),
("emacron", "e", "macron", Place::Above),
("Ebreve", "E", "breve", Place::Above),
("ebreve", "e", "breve", Place::Above),
("Edotaccent", "E", "dotaccent", Place::Above),
("edotaccent", "e", "dotaccent", Place::Above),
("Eogonek", "E", "ogonek", Place::Ogonek),
("eogonek", "e", "ogonek", Place::Ogonek),
("Ecaron", "E", "caron", Place::Above),
("ecaron", "e", "caron", Place::Above),
("Gcircumflex", "G", "circumflex", Place::Above),
("gcircumflex", "g", "circumflex", Place::Above),
("Gbreve", "G", "breve", Place::Above),
("gbreve", "g", "breve", Place::Above),
("Gdotaccent", "G", "dotaccent", Place::Above),
("gdotaccent", "g", "dotaccent", Place::Above),
("Hcircumflex", "H", "circumflex", Place::Above),
("hcircumflex", "h", "circumflex", Place::Above),
("Itilde", "I", "tilde", Place::Above),
("itilde", "dotlessi", "tilde", Place::Above),
("Imacron", "I", "macron", Place::Above),
("imacron", "dotlessi", "macron", Place::Above),
("Ibreve", "I", "breve", Place::Above),
("ibreve", "dotlessi", "breve", Place::Above),
("Iogonek", "I", "ogonek", Place::Ogonek),
("iogonek", "i", "ogonek", Place::Ogonek),
("Idotaccent", "I", "dotaccent", Place::Above),
("Jcircumflex", "J", "circumflex", Place::Above),
("Lacute", "L", "acute", Place::Above),
("lacute", "l", "acute", Place::Above),
("Nacute", "N", "acute", Place::Above),
("nacute", "n", "acute", Place::Above),
("Ncaron", "N", "caron", Place::Above),
("ncaron", "n", "caron", Place::Above),
("Omacron", "O", "macron", Place::Above),
("omacron", "o", "macron", Place::Above),
("Obreve", "O", "breve", Place::Above),
("obreve", "o", "breve", Place::Above),
("Ohungarumlaut", "O", "hungarumlaut", Place::Above),
("ohungarumlaut", "o", "hungarumlaut", Place::Above),
("Racute", "R", "acute", Place::Above),
("racute", "r", "acute", Place::Above),
("Rcaron", "R", "caron", Place::Above),
("rcaron", "r", "caron", Place::Above),
("Sacute", "S", "acute", Place::Above),
("sacute", "s", "acute", Place::Above),
("Scircumflex", "S", "circumflex", Place::Above),
("scircumflex", "s", "circumflex", Place::Above),
("Scedilla", "S", "cedilla", Place::Below),
("scedilla", "s", "cedilla", Place::Below),
("Tcedilla", "T", "cedilla", Place::Below),
("tcedilla", "t", "cedilla", Place::Below),
("Tcaron", "T", "caron", Place::Above),
("Utilde", "U", "tilde", Place::Above),
("utilde", "u", "tilde", Place::Above),
("Umacron", "U", "macron", Place::Above),
("umacron", "u", "macron", Place::Above),
("Ubreve", "U", "breve", Place::Above),
("ubreve", "u", "breve", Place::Above),
("Uring", "U", "ring", Place::Above),
("uring", "u", "ring", Place::Above),
("Uhungarumlaut", "U", "hungarumlaut", Place::Above),
("uhungarumlaut", "u", "hungarumlaut", Place::Above),
("Uogonek", "U", "ogonek", Place::Ogonek),
("uogonek", "u", "ogonek", Place::Ogonek),
("Wcircumflex", "W", "circumflex", Place::Above),
("wcircumflex", "w", "circumflex", Place::Above),
("Ycircumflex", "Y", "circumflex", Place::Above),
("ycircumflex", "y", "circumflex", Place::Above),
("Zacute", "Z", "acute", Place::Above),
("zacute", "z", "acute", Place::Above),
("Zdotaccent", "Z", "dotaccent", Place::Above),
("zdotaccent", "z", "dotaccent", Place::Above),
];
/// PdfCraft patch: the base letter, accent and placement of an accented letter in
/// [`ACCENTED_LETTERS`], by glyph name (`uni011E` and `u011E` forms too).
fn accented_parts(name: &str) -> Option<(&'static str, &'static str, Place)> {
let name = normalized_glyph_name(name);
let by_name = |n: &str| {
ACCENTED_LETTERS
.iter()
.find(|(letter, ..)| *letter == n)
.map(|&(_, base, accent, place)| (base, accent, place))
};
by_name(name).or_else(|| by_name(glyph_names::get_reverse(glyph_name_to_unicode(name)?)?))
}