PDF: right-to-left text extracts in reading order

Lines with right-to-left text are wrapped in a marked span whose
ActualText is the line's text in reading order, so viewers that order
text by position (Preview, PDFKit) no longer return it reversed.

A base letter and its marks (Hebrew points) map glyph by glyph when
each glyph stands for one of the cluster's characters, and are drawn in
text order, so ToUnicode-only extraction reads each letter once with
its points after it. Marks stay in their run, placed by glyph offsets
(compensated for skew), instead of starting runs of their own.
Kashidas inserted by justification carry no text.

Fixes #392.
This commit is contained in:
aiongg
2026-10-10 23:29:46 +00:00
parent a127c6d5c3
commit 2b96dffda1
3 changed files with 352 additions and 21 deletions
+2
View File
@@ -200,6 +200,8 @@ pub fn parse_page_range(s: &str, count: usize) -> Result<Vec<usize>> {
Ok(out)
}
#[cfg(test)]
mod rtl_tests;
#[cfg(test)]
mod tests;
+190
View File
@@ -0,0 +1,190 @@
//! Right-to-left text in exported PDFs: content streams list it in reading order with marks after
//! their letters, lines with right-to-left text carry their reading-order text as ActualText, and
//! glyphs stay where composition put them.
use designcraft_compose::{Cache, ComposeOptions, compose_story};
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, SpreadRef, TextDirection};
use designcraft_geom::Rect;
use crate::tests::extract_text;
use crate::*;
const HEBREW: &str = "DC Test PDF Hebrew";
const ARABIC: &str = "DC Test PDF Arabic";
/// A stand-in Hebrew font: each letter and point has a glyph of its own (Latin capitals and
/// combining accents of the bundled face), so the ToUnicode map is unambiguous.
fn hebrew_font() {
let letters = ['ד', 'ו', 'ש', 'ר', 'ה', 'י', 'א', 'ל', 'ב', 'ם', 'צ', 'ח', 'ק', 'ע'];
let points = ['\u{5B7}', '\u{5B0}', '\u{5B8}', '\u{5B4}', '\u{5B2}', '\u{5B9}'];
let accents = ['\u{300}', '\u{301}', '\u{302}', '\u{303}', '\u{304}', '\u{308}'];
let mut map: Vec<(char, char)> = letters.iter().zip('A'..).map(|(h, l)| (*h, l)).collect();
map.extend(points.iter().copied().zip(accents));
map.extend(" &abcdef".chars().map(|c| (c, c)));
designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::font_mapping(HEBREW, &map).expect("font"));
}
/// A stand-in Arabic font with a tatweel, so justification inserts kashidas.
fn arabic_font() {
let map = [('ب', 'b'), ('س', 's'), ('م', 'm'), ('ا', 'a'), ('ل', 'l'), ('ه', 'h'), ('\u{640}', '_'), (' ', ' ')];
designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::font_mapping(ARABIC, &map).expect("font"));
}
/// One frame of `text` in `family` with paragraph attributes `para`.
fn doc(text: &str, family: &str, r: Rect, para: ParaAttrs) -> Document {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
d.add_text_frame(SpreadRef::Doc(0), r, lid, text, ParaFormat { para, ..ParaFormat::default() }).unwrap();
let sid = *d.stories.keys().next().unwrap();
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(family.into()));
d
}
fn rtl() -> ParaAttrs {
ParaAttrs { direction: Some(TextDirection::RightToLeft), ..ParaAttrs::default() }
}
/// The ActualText of every line span (marked content with an MCID) on the first page.
fn line_actual_texts(bytes: &[u8]) -> Vec<String> {
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Dict, Object};
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse");
let page = &pdf.pages()[0];
let mut out = Vec::new();
let mut ops = page.typed_operations();
while let Some(op) = ops.next() {
let TypedInstruction::BeginMarkedContentWithProperties(op) = op else { continue };
let Object::Dict(props) = op.1 else { continue };
let props: &Dict<'_> = props;
if !props.contains_key(b"MCID") {
continue;
}
let Some(s) = props.get::<hayro_syntax::object::String>(b"ActualText") else { continue };
let units: Vec<u16> = s.as_bytes().chunks_exact(2).map(|b| u16::from_be_bytes([b[0], b[1]])).collect();
let text = String::from_utf16(&units).expect("UTF-16");
out.push(text.trim_start_matches('\u{FEFF}').to_string());
}
out
}
#[test]
fn hebrew_extracts_in_reading_order_with_points_on_their_letters() {
hebrew_font();
let lines = ["דוד & שרה", "ישראל אַבְרָהָם יִצְחָק וְיַעֲקֹב", "דוד abc שרה"];
let d = doc(&lines.join("\n"), HEBREW, Rect::new(36.0, 36.0, 576.0, 300.0), rtl());
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
// Glyph by glyph (ToUnicode, content order): each point right after its letter, once.
assert_eq!(extract_text(&bytes)[0], lines.concat());
// Viewers that order text by position read each line's ActualText.
assert_eq!(line_actual_texts(&bytes), lines);
// Tagged, the span is the line's tag under its paragraph.
let tagged = export_pdf(&d, &Cache::new(), &PdfOptions { tagged: true, ..PdfOptions::default() }).unwrap();
assert_eq!(line_actual_texts(&tagged), lines);
assert_eq!(extract_text(&tagged)[0], lines.concat());
}
#[test]
fn hebrew_words_in_left_to_right_lines_extract_in_reading_order() {
hebrew_font();
let text = "abc דוד & שרה def";
let d = doc(text, HEBREW, Rect::new(36.0, 36.0, 576.0, 300.0), ParaAttrs::default());
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert_eq!(extract_text(&bytes)[0], text);
assert_eq!(line_actual_texts(&bytes), [text]);
// A line without right-to-left text has no line span.
let d = doc("abc def", HEBREW, Rect::new(36.0, 36.0, 576.0, 300.0), ParaAttrs::default());
assert!(line_actual_texts(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap()).is_empty());
}
/// Device origin of every glyph drawn on the first page.
fn glyph_origins(bytes: &[u8]) -> Vec<kurbo::Point> {
use hayro_interpret::font::Glyph;
use hayro_interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
interpret_page,
};
struct Rec(Vec<kurbo::Point>);
impl Device<'_> for Rec {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, _: &PathDrawMode) {}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(&mut self, g: &Glyph<'_>, xf: kurbo::Affine, gxf: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {
if let Glyph::Outline(_) = g {
self.0.push((xf * gxf) * kurbo::Point::ZERO);
}
}
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse");
let cache = InterpreterCache::new();
let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default());
let mut rec = Rec(Vec::new());
interpret_page(&pdf.pages()[0], &mut ctx, &mut rec);
rec.0
}
#[test]
fn pointed_letters_are_drawn_where_composed() {
hebrew_font();
let text = "יִצְחָק אַבְרָהָם\nיִצְחָק אַבְרָהָם";
let mut d = doc(text, HEBREW, Rect::new(36.0, 36.0, 576.0, 300.0), rtl());
let sid = *d.stories.keys().next().unwrap();
// The second paragraph is skewed: points keep their place relative to their letters.
let second = text.find('\n').unwrap() + 1;
d.story_mut(sid).unwrap().format_chars(second..text.len(), |f| f.over.skew = Some(12.0));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let composed: Vec<kurbo::Point> =
cs.frames[0].lines.iter().flat_map(|l| l.glyphs.iter().filter(|g| g.visible).map(|g| kurbo::Point::new(g.x, l.baseline + g.y))).collect();
assert!(composed.iter().any(|p| (p.y - composed[0].y).abs() > 1.0 && (p.y - composed[0].y).abs() < 20.0), "the points sit off the baseline");
let drawn = glyph_origins(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap());
assert_eq!(drawn.len(), composed.len());
// The page places the frame (y grows upwards on the page): match the two through their means.
let mean = |ps: &[kurbo::Point]| ps.iter().fold(kurbo::Vec2::ZERO, |a, p| a + p.to_vec2()) / ps.len() as f64;
let (c, k) = (mean(&composed), mean(&drawn));
let key = |x: f64, y: f64| ((x * 100.0).round() as i64, (y * 100.0).round() as i64);
let mut want: Vec<(i64, i64)> = composed.iter().map(|p| key(p.x - c.x, c.y - p.y)).collect();
let mut got: Vec<(i64, i64)> = drawn.iter().map(|p| key(p.x - k.x, p.y - k.y)).collect();
want.sort_unstable();
got.sort_unstable();
let off: Vec<_> = want.iter().zip(&got).filter(|(w, g)| (w.0 - g.0).abs() > 1 || (w.1 - g.1).abs() > 1).collect();
assert!(off.is_empty(), "{off:?}");
}
#[test]
fn kashidas_extract_as_no_text() {
arabic_font();
let text = "بسم الله بسم الله بسم الله بسم الله بسم الله بسم الله بسم الله";
let para = ParaAttrs { align: Some(Align::FullyJustified), ..rtl() };
let d = doc(text, ARABIC, Rect::new(36.0, 36.0, 186.0, 300.0), para);
let sid = *d.stories.keys().next().unwrap();
let cs = compose_story(&d, sid, &ComposeOptions::default());
let kashida = |g: &designcraft_compose::PlacedGlyph| g.len == 0 && g.gid == g.face.glyph_for('\u{640}');
assert!(cs.frames[0].lines.iter().any(|l| l.glyphs.iter().any(kashida)), "justification inserts kashidas");
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
let words: Vec<String> = extract_text(&bytes)[0].split([' ', '\u{FFFD}']).filter(|w| !w.is_empty()).map(str::to_string).collect();
assert!(words.iter().all(|w| w == "بسم" || w == "الله"), "{words:?}");
let spans = line_actual_texts(&bytes);
assert!(spans.len() > 1 && spans.iter().all(|t| !t.contains('\u{640}') && text.contains(t.trim())), "{spans:?}");
}
#[test]
fn arabic_ligatures_extract_their_letters() {
// Lam-alef in a system font with Arabic (skipped without one).
let text = "لا سلام";
let mut d = doc(text, designcraft_fonts::DEFAULT_FAMILY, Rect::new(36.0, 36.0, 576.0, 300.0), rtl());
d.settings.glyph_fallback = true;
let sid = *d.stories.keys().next().unwrap();
let cs = compose_story(&d, sid, &ComposeOptions::default());
let glyphs = &cs.frames[0].lines[0].glyphs;
if glyphs.iter().any(|g| g.gid == 0) || !glyphs.iter().any(|g| g.byte == 0 && g.len == "لا".len()) {
return;
}
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
assert_eq!(extract_text(&bytes)[0], text);
assert_eq!(line_actual_texts(&bytes), [text]);
}
+160 -21
View File
@@ -1,10 +1,14 @@
//! Composed text → PDF text objects with embedded (subsetted) fonts.
//!
//! Each line is split into runs of glyphs sharing face, style, scale and baseline offset. A run is
//! drawn with one `draw_glyphs` call: the run origin, horizontal scale and skew go into the
//! transform, glyph advances are taken from the composed x positions (so justification, tracking
//! and kerning are exact), and every glyph carries the story text it came from so viewers can
//! select, search and copy it.
//! Each line is split into runs of glyphs sharing face, style and scale. A run is drawn with one
//! `draw_glyphs` call: the run origin, horizontal scale and skew go into the transform, glyph
//! advances and offsets are taken from the composed positions (so justification, tracking, kerning
//! and mark positioning are exact), and every glyph carries the story text it came from so viewers
//! can select, search and copy it.
//!
//! Composed lines hold their glyphs in text order, so content streams list text in reading order
//! even where right-to-left text is drawn leftwards. Viewers that order text by position instead
//! read the ActualText that a line with right-to-left text carries.
use std::collections::HashMap;
use std::ops::Range;
@@ -36,7 +40,83 @@ fn same_run(a: &PlacedGlyph, b: &PlacedGlyph) -> bool {
&& a.style == b.style
&& (a.sx - b.sx).abs() < 1e-9
&& (a.sy - b.sy).abs() < 1e-9
&& (a.y - b.y).abs() < 1e-6
}
/// End of the run of `gs` that starts at `i`.
fn run_end(ft: &FrameText, gs: &[PlacedGlyph], i: usize) -> usize {
let mut j = i + 1;
while j < gs.len() && same_run(&gs[i], &gs[j]) && !(ft.vertical && (gs[j].upright || gs[j].tcy.is_some())) {
j += 1;
}
j
}
/// Clusters of several glyphs (a base and its marks, as in pointed Hebrew) map glyph by glyph
/// when every glyph stands for one of the cluster's characters, and are drawn in text order:
/// viewers that only read ToUnicode then get each character once, marks after their base. A
/// glyph stands for a character when it is the font's glyph for it, or when both are marks (a
/// mark's glyph may be a positional variant). Other clusters (ligatures, contextual forms) keep
/// one shared range, which krilla writes as ActualText. Returns the order to draw the glyphs in.
fn split_clusters(glyphs: &[PlacedGlyph], text: &str, ranges: &mut [Range<usize>]) -> Vec<usize> {
let mut order: Vec<usize> = (0..glyphs.len()).collect();
let mut i = 0;
while i < ranges.len() {
let r = ranges[i].clone();
let mut j = i + 1;
while ranges.get(j).is_some_and(|n| *n == r) {
j += 1;
}
if let (Some(cluster), Some(members)) = (text.get(r.clone()).filter(|_| j - i > 1), glyphs.get(i..j))
&& let Some(own) = cluster_chars(members, cluster)
{
for (m, (at, c)) in own.into_iter().enumerate() {
if let Some(range) = ranges.get_mut(i + m) {
*range = r.start + at..r.start + at + c.len_utf8();
}
}
if let Some(o) = order.get_mut(i..j) {
o.sort_by_key(|&m| ranges.get(m).map_or(0, |r| r.start));
}
}
i = j;
}
order
}
/// The character (byte offset in `cluster`, char) each glyph of a cluster stands for, if each
/// stands for a different one and all are covered.
fn cluster_chars(members: &[PlacedGlyph], cluster: &str) -> Option<Vec<(usize, char)>> {
let chars: Vec<(usize, char)> = cluster.char_indices().collect();
if chars.len() != members.len() {
return None;
}
let mut own: Vec<Option<usize>> = vec![None; members.len()];
let mut used = vec![false; chars.len()];
for (m, g) in members.iter().enumerate() {
if let Some(k) = (0..chars.len()).find(|&k| !used[k] && g.face.glyph_for(chars[k].1) == g.gid) {
used[k] = true;
own[m] = Some(k);
}
}
// The remaining glyphs and characters must all be marks; they pair up in text order (a
// right-to-left cluster lists its glyphs right to left).
let mut rest: Vec<usize> = (0..members.len()).filter(|&m| own[m].is_none()).collect();
if members.first().is_some_and(|g| g.rtl) {
rest.reverse();
}
let marks: Vec<usize> = (0..chars.len()).filter(|&k| !used[k]).collect();
if rest.len() != marks.len() {
return None;
}
for (&m, &k) in rest.iter().zip(&marks) {
let g = members.get(m)?;
let c = chars.get(k)?.1;
if !g.face.glyph_is_mark(g.gid) || !g.face.glyph_is_mark(g.face.glyph_for(c)) {
return None;
}
own[m] = Some(k);
}
own.into_iter().map(|k| chars.get(k?).copied()).collect()
}
/// Merge bars of one paint layer; underlines and strikes must never be merged together.
@@ -143,6 +223,7 @@ impl Exporter<'_> {
}
for l in &ft.lines {
let gs = &l.glyphs;
let line_span = self.start_rtl_line(s, cs, ft, gs, story);
let mut i = 0;
while i < gs.len() {
let g = &gs[i];
@@ -160,13 +241,17 @@ impl Exporter<'_> {
i += 1;
continue;
}
let mut j = i + 1;
while j < gs.len() && same_run(g, &gs[j]) && !(ft.vertical && (gs[j].upright || gs[j].tcy.is_some())) {
j += 1;
let j = run_end(ft, gs, i);
if line_span {
self.run(s, cs, &gs[i..j], l.baseline, story);
} else {
self.tagged_run(s, cs, &gs[i..j], l.baseline, story);
}
self.tagged_run(s, cs, &gs[i..j], l.baseline, story);
i = j;
}
if line_span {
s.end_tagged();
}
}
self.as_artifact(s, |me, s| {
for (rule, r) in text_rules(cs, ft, false) {
@@ -184,6 +269,47 @@ impl Exporter<'_> {
});
}
/// Open a span over a line with right-to-left text whose ActualText is the line's text in
/// reading order (tagged under its paragraph while a story is being tagged). Viewers that order
/// text by position would otherwise read it in visual order. Marked content can't nest, so only
/// in untagged exports and in tagged stories (not inside artifacts or figures).
fn start_rtl_line(&mut self, s: &mut Surface, cs: &ComposedStory, ft: &FrameText, gs: &[PlacedGlyph], story: &str) -> bool {
if ft.vertical || !gs.iter().any(|g| g.rtl && g.visible) || (self.opts.tagged && self.tag_story.is_none()) {
return false;
}
let mut text = String::new();
let mut i = 0;
while i < gs.len() {
let g = &gs[i];
if !g.visible || g.sx <= 0.0 || g.sy <= 0.0 {
i += 1;
continue;
}
let j = run_end(ft, gs, i);
// Only runs `run` draws.
let painted = cs.styles.get(g.style as usize).is_some_and(|st| {
self.swatch_color(&st.fill, st.fill_tint).is_some()
|| (st.stroke != designcraft_color::swatch::NONE
&& st.stroke_weight > 0.0
&& self.swatch_color(&st.stroke, st.stroke_tint).is_some())
});
if painted && let Some(run) = gs.get(i..j) {
text.push_str(&self.run_text(run, story).0);
}
i = j;
}
if text.is_empty() {
return false;
}
let id = s.start_tagged(ContentTag::Span(SpanTag::empty().with_actual_text(Some(&text))));
if let Some(sid) = self.tag_story {
let byte = gs.first().map_or(0, |g| g.byte);
let pi = self.doc.story(sid).map_or(0, |st| st.para_at(byte.min(st.len())));
self.para_tags.entry((sid, pi)).or_default().push(id);
}
true
}
/// Table fragments: cell fills, cell text (real text), edges and the border.
fn tables(&mut self, s: &mut Surface, ft: &FrameText) {
for t in &ft.tables {
@@ -263,6 +389,10 @@ impl Exporter<'_> {
} else if g.len == 0 && prev_src == Some(g.byte) && !ranges.is_empty() {
// Second glyph of a multi-glyph cluster: share the cluster's text.
ranges[ranges.len() - 1].clone()
} else if g.len == 0 && cmap.get(&g.gid) == Some(&'\u{0640}') {
// A kashida that justification inserted: drawn, but no text of its own.
prev_src = None;
text.len()..text.len()
} else {
// Inserted glyphs (page numbers, list labels, hyphens): map back through the font.
prev_src = None;
@@ -298,33 +428,40 @@ impl Exporter<'_> {
let face = g0.face;
let size = g0.sy * face.upem;
let hs = g0.sx / g0.sy;
let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
let tan = if st.skew != 0.0 { st.skew.to_radians().tan() } else { 0.0 };
let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -tan, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
let origin = Affine::translate((g0.x, baseline + g0.y)) * skew * Affine::scale_non_uniform(hs, 1.0);
// A missing glyph is drawn as its box: PDF/A and PDF/UA forbid showing .notdef as text.
if g0.gid == 0 {
self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight);
self.outline_run(s, glyphs, origin, tan, size, fill, stroke, st.stroke_weight);
return;
}
let Some(font) = self.font(&face) else {
self.warn(format!("font `{} {}` could not be embedded; its text was drawn as outlines", face.family, face.style));
self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight);
self.outline_run(s, glyphs, origin, tan, size, fill, stroke, st.stroke_weight);
return;
};
let (text, ranges) = self.run_text(glyphs, story);
let (text, mut ranges) = self.run_text(glyphs, story);
let order = split_clusters(glyphs, &text, &mut ranges);
let k = 1.0 / (hs * size);
let kg: Vec<KrillaGlyph> = glyphs
// Glyphs are placed absolutely: each advance reaches the next glyph drawn, a vertical offset
// (a mark above or below its base) is undone by the skew so it lands where it was composed.
let kg: Vec<KrillaGlyph> = order
.iter()
.zip(ranges)
.enumerate()
.map(|(i, (g, r))| {
let next = glyphs.get(i + 1).map_or(g.x + g.adv, |n| n.x);
KrillaGlyph::new(GlyphId::new(g.gid), ((next - g.x) * k) as f32, 0.0, 0.0, 0.0, r, None)
.filter_map(|(n, &i)| {
let g = glyphs.get(i)?;
let next = order.get(n + 1).and_then(|&m| glyphs.get(m)).map_or(g.x + g.adv, |m| m.x);
let dy = g.y - g0.y;
let r = ranges.get(i)?.clone();
Some(KrillaGlyph::new(GlyphId::new(g.gid), ((next - g.x) * k) as f32, (tan * dy * k) as f32, (-dy / size) as f32, 0.0, r, None))
})
.collect();
let start = order.first().and_then(|&i| glyphs.get(i)).map_or(0.0, |g| (g.x - g0.x) / hs);
s.push_transform(&tf(origin));
s.set_fill(fill.map(|c| solid_fill(c, 1.0)));
s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: st.stroke_weight as f32, ..Default::default() }));
s.draw_glyphs(Point::from_xy(0.0, 0.0), &kg, font, &text, size as f32, false);
s.draw_glyphs(Point::from_xy(start as f32, 0.0), &kg, font, &text, size as f32, false);
s.set_fill(None);
s.set_stroke(None);
s.pop();
@@ -337,6 +474,7 @@ impl Exporter<'_> {
s: &mut Surface,
glyphs: &[PlacedGlyph],
origin: Affine,
tan: f64,
size: f64,
fill: Option<krilla::color::Color>,
stroke: Option<krilla::color::Color>,
@@ -347,7 +485,8 @@ impl Exporter<'_> {
let k = size / g0.face.upem;
let mut bp = BezPath::new();
for g in glyphs {
let a = Affine::translate(((g.x - g0.x) / (g0.sx / g0.sy), 0.0)) * Affine::scale(k);
let dy = g.y - g0.y;
let a = Affine::translate(((g.x - g0.x + tan * dy) / (g0.sx / g0.sy), dy)) * Affine::scale(k);
let mut o = (*db.outline(&g.face, g.gid)).clone();
o.apply_affine(a);
bp.extend(o.iter());