mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 13:57:18 -06:00
Merge pull request #4602 from Silvertree2010/perf/export-speed
Speed up PDF export by about 24 %
This commit is contained in:
@@ -64,6 +64,11 @@ pub fn is_cluster_boundary(text: &str, off: usize) -> bool {
|
||||
/// Byte offsets where clusters start (graphemes, joined per [`joins_cluster`]), in order; the
|
||||
/// first is 0 for non-empty text.
|
||||
pub fn cluster_starts(text: &str) -> Vec<usize> {
|
||||
// ASCII: every character starts a grapheme except LF after CR, and none joins (UAX #29).
|
||||
if text.is_ascii() {
|
||||
let b = text.as_bytes();
|
||||
return (0..b.len()).filter(|&i| !(i > 0 && b.get(i - 1) == Some(&b'\r') && b.get(i) == Some(&b'\n'))).collect();
|
||||
}
|
||||
let mut out: Vec<usize> = Vec::new();
|
||||
let mut prev: Option<char> = None;
|
||||
for (i, g) in text.grapheme_indices(true) {
|
||||
@@ -188,6 +193,22 @@ mod tests {
|
||||
assert_eq!(split(kinzi, &cluster_starts(kinzi)), vec!["အ", "င်္ဂ", "လိ", "ပ်"]);
|
||||
}
|
||||
|
||||
/// The ASCII shortcut gives the grapheme rules' answer.
|
||||
#[test]
|
||||
fn ascii_cluster_starts_match_the_graphemes() {
|
||||
let graphemes = |t: &str| -> Vec<usize> { t.grapheme_indices(true).map(|(i, _)| i).collect() };
|
||||
let all: String = (0u8..128).map(char::from).collect();
|
||||
for t in ["", "a", "Hello, world.", "\r\n", "a\r\nb\n\rc\r\r\n\n", "\t \u{7f}x\u{0}", all.as_str()] {
|
||||
assert_eq!(cluster_starts(t), graphemes(t), "{t:?}");
|
||||
}
|
||||
for a in 0u8..128 {
|
||||
for b in 0u8..128 {
|
||||
let t = format!("{}{}", char::from(a), char::from(b));
|
||||
assert_eq!(cluster_starts(&t), graphemes(&t), "{t:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn khmer_subscripts_and_indic_conjuncts_are_one_cluster() {
|
||||
// ខ្មែរ: ខ + coeng + ម + ែ, then រ.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Export timing: saves a long generated document (the built-in sample document repeated) through
|
||||
//! the real save path, the way `file.save` / `file.export` write it, and prints wall times.
|
||||
//!
|
||||
//! `cargo run --release -p wordcraft-engine --example export_bench -- [ext] [copies] [runs] [out]`
|
||||
//!
|
||||
//! Defaults: `pdf`, 40 copies (about 100 pages), 7 runs. With `out` the last run's bytes are
|
||||
//! written there, to compare the output of two builds.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use wordcraft_engine::doc::props::CharProps;
|
||||
use wordcraft_engine::doc::{Block, Document};
|
||||
use wordcraft_engine::{io, sample};
|
||||
|
||||
/// The sample document's body `copies` times, each paragraph ending in its copy number so no two
|
||||
/// copies share text (as the chapters of a real long document don't).
|
||||
fn long_document(copies: usize) -> Document {
|
||||
let mut d = sample::sample_document();
|
||||
// A fixed creation date: the PDF's dates (and the ID derived from them) stay the same from run to run.
|
||||
d.core.created = "2026-01-01T00:00:00Z".into();
|
||||
let one: Vec<Block> = d.body.iter().map(|b| (**b).clone()).collect();
|
||||
d.body.clear();
|
||||
for i in 0..copies {
|
||||
for b in &one {
|
||||
let mut b = b.clone();
|
||||
if let Block::Para(p) = &mut b {
|
||||
let at = p.len();
|
||||
let _ = p.insert_text(at, &format!(" {i}"), &CharProps::default());
|
||||
}
|
||||
d.body.push(Arc::new(b));
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let ext = args.first().map_or("pdf", String::as_str);
|
||||
let copies = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(40);
|
||||
let runs = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(7usize).max(1);
|
||||
let doc = long_document(copies);
|
||||
let name = format!("bench.{ext}");
|
||||
let mut ms = Vec::with_capacity(runs);
|
||||
let mut bytes = Vec::new();
|
||||
for _ in 0..runs {
|
||||
let t = Instant::now();
|
||||
bytes = io::save_bytes(&name, &doc)?;
|
||||
ms.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
let mut sorted = ms.clone();
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
let median = sorted.get(sorted.len() / 2).copied().unwrap_or_default();
|
||||
let min = sorted.first().copied().unwrap_or_default();
|
||||
println!(
|
||||
"{ext}: {copies} copies, {} bytes, runs {:?} ms, median {median:.1} ms, min {min:.1} ms",
|
||||
bytes.len(),
|
||||
ms.iter().map(|m| m.round()).collect::<Vec<_>>()
|
||||
);
|
||||
if let Some(out) = args.get(3) {
|
||||
std::fs::write(out, &bytes).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+134
-3
@@ -19,7 +19,7 @@ pub use fontdb::{
|
||||
base_style, bundled, decode_font_file, is_font_file_name, name_language_matches, sniff, system_font_dirs,
|
||||
};
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
|
||||
use harfrust::{Direction, ShapeOptions, ShapePlan, ShapePlanKey, Shaper, Tag, UnicodeBuffer};
|
||||
pub use kurbo::BezPath;
|
||||
pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};
|
||||
use skrifa::MetadataProvider;
|
||||
@@ -133,7 +133,9 @@ pub fn feature_value(tag: &[u8; 4], value: u32) -> Feature {
|
||||
/// A strong right-to-left character (Hebrew, Arabic, …)?
|
||||
pub fn is_rtl(c: char) -> bool {
|
||||
use unicode_bidi::BidiClass::{AL, R};
|
||||
matches!(unicode_bidi::bidi_class(c), R | AL)
|
||||
// Nothing before the Hebrew block is right to left: Latin, Greek and Cyrillic text skips the
|
||||
// table search.
|
||||
c >= '\u{590}' && matches!(unicode_bidi::bidi_class(c), R | AL)
|
||||
}
|
||||
|
||||
/// Maximal runs of one direction (neutrals join the run they're in): (byte range, right-to-left).
|
||||
@@ -219,7 +221,8 @@ fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(ch
|
||||
}
|
||||
buf.guess_segment_properties();
|
||||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
|
||||
let plan = shape_plan(face, &shaper, &buf, features);
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().plan(plan.as_deref()).features(features));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
out.push(ShapedGlyph {
|
||||
gid: info.glyph_id,
|
||||
@@ -245,6 +248,69 @@ fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(ch
|
||||
out
|
||||
}
|
||||
|
||||
/// How many shape plans each thread keeps: the oldest is dropped to make room, so a thread never
|
||||
/// holds more than this many, whatever fonts, scripts and feature sets it shapes.
|
||||
const MAX_SHAPE_PLANS: usize = 64;
|
||||
|
||||
/// The face a kept shape plan was made for. Face ids come from one process-wide counter
|
||||
/// (`NEXT_ID`, taken once when a face is built), so no two faces share one; the font bytes'
|
||||
/// address and length and the face index are kept too, so a plan is only ever reused for the
|
||||
/// font data it was made from, even if the counter were to wrap after 2^32 faces.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
struct PlanFace {
|
||||
id: u32,
|
||||
data: usize,
|
||||
len: usize,
|
||||
index: u32,
|
||||
}
|
||||
|
||||
impl PlanFace {
|
||||
fn of(face: &FontFace) -> PlanFace {
|
||||
let data = face.data();
|
||||
PlanFace { id: face.id(), data: data.as_ptr() as usize, len: data.len(), index: face.index() }
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Shape plans made so far on this thread, by face: making one (the face's lookups for a
|
||||
/// script, direction and feature set) can take longer than shaping the run.
|
||||
static SHAPE_PLANS: std::cell::RefCell<Vec<(PlanFace, std::rc::Rc<ShapePlan>)>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// The plan the shaper would make for `buf` in `face` with `features`, made once per thread (the
|
||||
/// oldest dropped past [`MAX_SHAPE_PLANS`]). A kept plan is used only for the same face and when
|
||||
/// harfrust's own [`ShapePlanKey::matches`] accepts it (script, direction, language, the face's
|
||||
/// variation instance and the features). `None` leaves making it to the shaper.
|
||||
fn shape_plan(face: &FontFace, shaper: &Shaper, buf: &UnicodeBuffer, features: &[Feature]) -> Option<std::rc::Rc<ShapePlan>> {
|
||||
// The buffer's script is unset when it reads as unknown: the guess never sets that.
|
||||
let script = Some(buf.script()).filter(|s| *s != harfrust::script::UNKNOWN);
|
||||
let language = buf.language();
|
||||
let direction = buf.direction();
|
||||
if direction == Direction::Invalid {
|
||||
return None;
|
||||
}
|
||||
let key = ShapePlanKey::new(script, direction).language(language.as_ref()).instance(face.instance.as_ref()).features(features);
|
||||
let of = PlanFace::of(face);
|
||||
SHAPE_PLANS.with(|cell| {
|
||||
let mut plans = cell.try_borrow_mut().ok()?;
|
||||
if let Some((_, plan)) = plans.iter().find(|(f, plan)| *f == of && key.matches(plan)) {
|
||||
return Some(plan.clone());
|
||||
}
|
||||
let plan = std::rc::Rc::new(ShapePlan::new(shaper, direction, script, language.as_ref(), features));
|
||||
if plans.len() >= MAX_SHAPE_PLANS {
|
||||
plans.remove(0);
|
||||
}
|
||||
plans.push((of, plan.clone()));
|
||||
Some(plan)
|
||||
})
|
||||
}
|
||||
|
||||
/// How many shape plans this thread keeps.
|
||||
#[cfg(test)]
|
||||
fn kept_shape_plans() -> usize {
|
||||
SHAPE_PLANS.with(|cell| cell.try_borrow().map_or(0, |p| p.len()))
|
||||
}
|
||||
|
||||
/// Glyph id of the first of `chars` the face has (0 = .notdef).
|
||||
pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 {
|
||||
chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0)
|
||||
@@ -254,6 +320,15 @@ pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The shortcut before the Hebrew block gives the bidi class's answer for every character.
|
||||
#[test]
|
||||
fn is_rtl_matches_the_bidi_class() {
|
||||
use unicode_bidi::BidiClass::{AL, R};
|
||||
for c in (0..=0x10ffff).filter_map(char::from_u32) {
|
||||
assert_eq!(is_rtl(c), matches!(unicode_bidi::bidi_class(c), R | AL), "{:04x}", c as u32);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_resort_face_parses() {
|
||||
let f = fontdb::last_resort_face();
|
||||
@@ -282,6 +357,62 @@ mod tests {
|
||||
assert!(g.iter().all(|g| g.x_advance > 0 && g.gid != 0));
|
||||
}
|
||||
|
||||
/// Shaping with a plan kept from an earlier run gives what the shaper's own plan gives.
|
||||
#[test]
|
||||
fn kept_shape_plans_shape_as_fresh_ones() {
|
||||
let fresh = |face: &FontFace, text: &str, features: &[Feature], rtl: bool| -> Vec<(u32, u32, i32, i32, i32)> {
|
||||
let Some(hb) = face.hb() else { return Vec::new() };
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
buf.push_str(text);
|
||||
buf.guess_segment_properties();
|
||||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
|
||||
gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, i.cluster, p.x_advance, p.x_offset, p.y_offset)).collect()
|
||||
};
|
||||
let kept = |face: &FontFace, text: &str, features: &[Feature], rtl: bool| -> Vec<(u32, u32, i32, i32, i32)> {
|
||||
shape_dir(face, text, features, &|c| c, rtl).iter().map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset)).collect()
|
||||
};
|
||||
let db = FontDb::global();
|
||||
let faces = [db.face(DEFAULT_FAMILY, "Regular"), db.face(DEFAULT_FAMILY, "Italic"), db.face("Source Sans 3", "Regular")];
|
||||
let features =
|
||||
[vec![], vec![feature("-liga").unwrap()], vec![feature_value(b"kern", 0)], vec![feature("smcp").unwrap(), feature("-kern").unwrap()]];
|
||||
for _ in 0..2 {
|
||||
for face in &faces {
|
||||
for f in &features {
|
||||
for (text, rtl) in [("office affine", false), ("12, 34.", false), ("AVATAR Wolf", false), ("abc", true), ("", false)] {
|
||||
assert_eq!(kept(face, text, f, rtl), fresh(face, text, f, rtl), "{} {text:?} {f:?} {rtl}", face.family);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// However many feature sets and directions a thread shapes with, it keeps at most
|
||||
/// [`MAX_SHAPE_PLANS`] plans, and shaping still matches a fresh plan once old ones are dropped.
|
||||
#[test]
|
||||
fn kept_shape_plans_are_bounded() {
|
||||
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
||||
let fresh = |features: &[Feature], rtl: bool| -> Vec<(u32, i32)> {
|
||||
let Some(hb) = face.hb() else { return Vec::new() };
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
buf.push_str("office");
|
||||
buf.guess_segment_properties();
|
||||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
|
||||
gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, p.x_advance)).collect()
|
||||
};
|
||||
for n in 0..3 * MAX_SHAPE_PLANS as u32 {
|
||||
let features = [feature_value(b"kern", n % 2), feature_value(b"liga", n)];
|
||||
let rtl = n % 3 == 0;
|
||||
let kept: Vec<(u32, i32)> = shape_dir(&face, "office", &features, &|c| c, rtl).iter().map(|g| (g.gid, g.x_advance)).collect();
|
||||
assert_eq!(kept, fresh(&features, rtl), "{n}");
|
||||
assert!(kept_shape_plans() <= MAX_SHAPE_PLANS, "{}", kept_shape_plans());
|
||||
}
|
||||
assert_eq!(kept_shape_plans(), MAX_SHAPE_PLANS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ligatures_can_be_disabled() {
|
||||
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
||||
|
||||
@@ -229,6 +229,36 @@ impl Ctx<'_> {
|
||||
pl
|
||||
}
|
||||
|
||||
/// `p` laid out on its own, as the paragraph after a kept one is measured before it is placed
|
||||
/// (no wrap areas, proofing, typed word or resized text boxes), through the cache when
|
||||
/// [`Ctx::para_labelled`] would lay it out with those same inputs: it is then not laid out a
|
||||
/// second time when it is placed. Counts nothing (list labels, equation numbers, page totals).
|
||||
/// `None` when the inputs differ.
|
||||
pub(crate) fn para_ahead(&mut self, p: &Paragraph, width: f32, label: Option<(String, Level)>) -> Option<Arc<ParaLayout>> {
|
||||
let resized = p
|
||||
.objects
|
||||
.iter()
|
||||
.any(|o| matches!(o, InlineObject::Shape { story: Some(_), float, .. } if float.wrap == Wrap::Inline && float.rot == 0.0));
|
||||
if self.opts.proofing || self.boxes.inside() || resized || self.hide_deleted != self.opts.hides_deleted(StoryRef::Body) {
|
||||
return None;
|
||||
}
|
||||
let kept = (self.eq_count, self.uses_totals);
|
||||
let (key, eq_number) = self.para_key(p, width, None, &[], label.as_ref(), &[]);
|
||||
(self.eq_count, self.uses_totals) = kept;
|
||||
if key.typing.is_some() {
|
||||
return None;
|
||||
}
|
||||
self.cache.used.insert(key.clone());
|
||||
if let Some(pl) = self.cache.paras.get(&key) {
|
||||
self.cache.hits += 1;
|
||||
return Some(pl.clone());
|
||||
}
|
||||
self.cache.misses += 1;
|
||||
let pl = Arc::new(para::layout_para(p, &self.para_env(width, label, None, &[], eq_number, None, &[])));
|
||||
self.cache.paras.insert(key, pl.clone());
|
||||
Some(pl)
|
||||
}
|
||||
|
||||
/// What `measure` reads from `p` laid out as [`Ctx::para_labelled`] would, remembering only
|
||||
/// the result: a table's columns measure every cell once at an unbounded width, and keeping
|
||||
/// those layouts would double what a long table holds (and walk them all again on each pass).
|
||||
|
||||
@@ -156,9 +156,17 @@ pub(super) fn lines(
|
||||
k += 1;
|
||||
continue;
|
||||
};
|
||||
let mut glyphs = Vec::new();
|
||||
let mut text = String::new();
|
||||
let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
|
||||
// Sized for the run's text clusters up front: no growing per glyph.
|
||||
let run = pl.clusters.get(k..line.c1.min(pl.clusters.len())).unwrap_or_default();
|
||||
let (n_glyphs, n_bytes) = run
|
||||
.iter()
|
||||
.take_while(|c| c.style == si)
|
||||
.filter(|c| matches!(c.kind, ClKind::Text | ClKind::Space))
|
||||
.fold((0usize, 0usize), |(g, b), c| (g + c.g1.saturating_sub(c.g0) as usize, b + c.end.saturating_sub(c.start)));
|
||||
let mut glyphs = Vec::with_capacity(n_glyphs);
|
||||
let mut text = String::with_capacity(n_bytes);
|
||||
let mut ranges: Vec<std::ops::Range<usize>> = Vec::with_capacity(n_glyphs);
|
||||
let symbol_font = wordcraft_doc::symbol_fonts::is_symbol_font(&st.rc.font);
|
||||
let run_start = k;
|
||||
while k < line.c1 {
|
||||
let Some(c) = pl.clusters.get(k) else { break };
|
||||
@@ -184,7 +192,7 @@ pub(super) fn lines(
|
||||
} else if let Some(p) = para {
|
||||
let t = p.text.get(c.start..c.end).unwrap_or("");
|
||||
// Symbol-font characters (Wingdings…) read as the Unicode symbols drawn.
|
||||
if wordcraft_doc::symbol_fonts::is_symbol_font(&st.rc.font) {
|
||||
if symbol_font {
|
||||
text.push_str(&wordcraft_doc::symbol_fonts::map_text(&st.rc.font, t));
|
||||
} else if st.rc.caps || st.rc.small_caps {
|
||||
// All Caps and Small Caps read as the capitals drawn (#1473), mapped
|
||||
|
||||
+24
-19
@@ -676,26 +676,31 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32, depth: usize, counte
|
||||
let rp = ctx.doc.styles.resolve_para(&p.props);
|
||||
let label =
|
||||
p.props.numbering.or(rp.numbering).filter(|n| n.num != 0).and_then(|n| counters.next_label(&ctx.doc.numbering, n.num, n.level));
|
||||
let env = para::ParaEnv {
|
||||
doc: ctx.doc,
|
||||
width,
|
||||
label,
|
||||
fields: &ctx.fields,
|
||||
show_hidden: ctx.opts.show_hidden,
|
||||
field_codes: ctx.opts.field_codes,
|
||||
hide_deleted: ctx.opts.hides_deleted(StoryRef::Body),
|
||||
table: None,
|
||||
proofing: None,
|
||||
typing_word: None,
|
||||
numeral: ctx.opts.numeral,
|
||||
exclusions: &[],
|
||||
eq_number: ctx.eq_count,
|
||||
read_line_spacing: if ctx.opts.view == ViewMode::Read { ctx.opts.read_line_spacing } else { None },
|
||||
grid: ctx.grid,
|
||||
fit_objects: false,
|
||||
fitted: &[],
|
||||
let pl = match ctx.para_ahead(p, width, label.clone()) {
|
||||
Some(pl) => pl,
|
||||
None => {
|
||||
let env = para::ParaEnv {
|
||||
doc: ctx.doc,
|
||||
width,
|
||||
label,
|
||||
fields: &ctx.fields,
|
||||
show_hidden: ctx.opts.show_hidden,
|
||||
field_codes: ctx.opts.field_codes,
|
||||
hide_deleted: ctx.opts.hides_deleted(StoryRef::Body),
|
||||
table: None,
|
||||
proofing: None,
|
||||
typing_word: None,
|
||||
numeral: ctx.opts.numeral,
|
||||
exclusions: &[],
|
||||
eq_number: ctx.eq_count,
|
||||
read_line_spacing: if ctx.opts.view == ViewMode::Read { ctx.opts.read_line_spacing } else { None },
|
||||
grid: ctx.grid,
|
||||
fit_objects: false,
|
||||
fitted: &[],
|
||||
};
|
||||
Arc::new(para::layout_para(p, &env))
|
||||
}
|
||||
};
|
||||
let pl = para::layout_para(p, &env);
|
||||
// Its border room, unless it joins the paragraph before (or after) in one box.
|
||||
let own = Edges::own(&pl, 0, pl.lines.len());
|
||||
let top = if joined(ctx.doc, &ctx.doc.body, block + 1, Some(block), None) { 0.0 } else { own.top };
|
||||
|
||||
@@ -423,6 +423,22 @@ mod label_spacing_tests {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The ASCII shortcuts give the bidi class's answer for every character.
|
||||
#[test]
|
||||
fn strong_and_mark_shortcuts_match_the_bidi_class() {
|
||||
use unicode_bidi::BidiClass::{AL, BN, L, NSM, R};
|
||||
for c in (0..=0x10ffff).filter_map(char::from_u32) {
|
||||
let class = unicode_bidi::bidi_class(c);
|
||||
let strong = match class {
|
||||
R | AL => Some(true),
|
||||
L => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(bidi::strong_rtl(c), strong, "{:04x}", c as u32);
|
||||
assert_eq!(chars::is_mark(c), matches!(class, NSM | BN) && !c.is_control(), "{:04x}", c as u32);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_ignore_roman_hint() {
|
||||
let mut d = Document::new();
|
||||
|
||||
@@ -15,6 +15,10 @@ pub(super) enum Dir<'l> {
|
||||
/// The direction of `c` if it is a strong letter (UAX #9 P2): `Some(true)` right to left.
|
||||
pub(super) fn strong_rtl(c: char) -> Option<bool> {
|
||||
use unicode_bidi::BidiClass::{AL, L, R};
|
||||
// In ASCII only the letters are strong (L).
|
||||
if c.is_ascii() {
|
||||
return c.is_ascii_alphabetic().then_some(false);
|
||||
}
|
||||
match unicode_bidi::bidi_class(c) {
|
||||
R | AL => Some(true),
|
||||
L => Some(false),
|
||||
|
||||
@@ -58,7 +58,8 @@ pub(super) fn caps_expands(c: char) -> bool {
|
||||
/// character before them: same font, same shaping run, so Arabic joining isn't broken.
|
||||
pub(super) fn is_mark(c: char) -> bool {
|
||||
use unicode_bidi::BidiClass::{BN, NSM};
|
||||
matches!(unicode_bidi::bidi_class(c), NSM | BN) && !c.is_control()
|
||||
// No ASCII character is a mark (its BN characters are controls).
|
||||
!c.is_ascii() && matches!(unicode_bidi::bidi_class(c), NSM | BN) && !c.is_control()
|
||||
}
|
||||
|
||||
/// A digit Word's Numeral modes substitute: ASCII or Arabic-Indic (U+0660–U+0669).
|
||||
|
||||
@@ -2463,6 +2463,30 @@ fn cache_reuses_unchanged_paragraphs() {
|
||||
assert_eq!(c.misses, misses + 1);
|
||||
}
|
||||
|
||||
/// The paragraph after a heading kept with it is measured before the heading is placed. That
|
||||
/// layout goes into the cache under the key it is placed with, so it is laid out once, and the
|
||||
/// pages are those of the uncached measurement (proofing on measures without the cache).
|
||||
#[test]
|
||||
fn kept_headings_measure_the_next_paragraph_through_the_cache() {
|
||||
let mut d = Document::new();
|
||||
let kept = ParaProps { keep_next: Some(true), ..Default::default() };
|
||||
for i in 0..40 {
|
||||
let mut heading = wordcraft_doc::Paragraph::with_text(&format!("Heading {i}"), Default::default());
|
||||
heading.props = kept.clone();
|
||||
d.body.push(wordcraft_doc::para_block(heading));
|
||||
let body = format!("Body text {i} under its heading, long enough to wrap onto a second line in the column. ").repeat(3);
|
||||
d.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&body, Default::default())));
|
||||
}
|
||||
let mut c = LayoutCache::new();
|
||||
let l = layout(&d, &mut c, &LayoutOptions::default());
|
||||
assert!(l.pages.len() > 2, "{}", l.pages.len());
|
||||
assert_eq!(c.len(), d.body.len(), "one layout per paragraph");
|
||||
assert_eq!(c.misses, d.body.len() as u64, "each paragraph laid out once");
|
||||
let proofed = layout(&d, &mut LayoutCache::new(), &LayoutOptions { proofing: true, ..Default::default() });
|
||||
let starts = |l: &DocLayout| (0..d.body.len()).map(|i| l.caret(&Pos::body(i, 0)).map(|c| (c.page, c.top.to_bits()))).collect::<Vec<_>>();
|
||||
assert_eq!(starts(&l), starts(&proofed));
|
||||
}
|
||||
|
||||
/// Autofit measures every cell at an unbounded width; the cache keeps what it measured, not
|
||||
/// those layouts, so a long table holds one layout per cell paragraph. Laying the document out
|
||||
/// again after an edit elsewhere measures nothing again.
|
||||
|
||||
@@ -110,8 +110,6 @@ impl Exporter<'_> {
|
||||
let placed: Vec<(u32, f32, f32, std::ops::Range<usize>)> =
|
||||
order.iter().filter_map(|&i| Some((glyphs.get(i).copied()?, ranges.get(i).cloned()?))).map(|((g, x, y), r)| (g, x, y, r)).collect();
|
||||
let Some(&(_, x0, y0, _)) = placed.first() else { return };
|
||||
// The run's baseline: the y most of its glyphs share (marks sit higher or lower).
|
||||
let base = baseline(glyphs).unwrap_or(y0);
|
||||
let kg: Vec<KrillaGlyph> = placed
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -125,6 +123,8 @@ impl Exporter<'_> {
|
||||
.collect();
|
||||
let pushed = italic;
|
||||
if italic {
|
||||
// The run's baseline: the y most of its glyphs share (marks sit higher or lower).
|
||||
let base = baseline(glyphs).unwrap_or(y0);
|
||||
s.push_transform(&Transform::from_row(1.0, 0.0, -0.21, 1.0, 0.21 * base, 0.0));
|
||||
}
|
||||
s.set_fill(Some(fill(color, alpha)));
|
||||
|
||||
@@ -116,6 +116,10 @@ pub(crate) fn visual_order(
|
||||
|
||||
/// The y most of a run's glyphs share: its baseline, which raised or lowered marks leave.
|
||||
pub(crate) fn baseline(glyphs: &[(u32, f32, f32)]) -> Option<f32> {
|
||||
let &(_, _, first) = glyphs.first()?;
|
||||
if glyphs.iter().all(|g| g.2.to_bits() == first.to_bits()) {
|
||||
return Some(first);
|
||||
}
|
||||
let mut counts: HashMap<u32, usize> = HashMap::new();
|
||||
for (_, _, y) in glyphs {
|
||||
*counts.entry(y.to_bits()).or_insert(0) += 1;
|
||||
|
||||
@@ -65,7 +65,7 @@ pub use export::{export, export_layout};
|
||||
#[cfg(test)]
|
||||
use exporter::Exporter;
|
||||
#[cfg(test)]
|
||||
use glyphs::{cluster_ends, left_to_right, split_clusters, visual_order};
|
||||
use glyphs::{baseline, cluster_ends, left_to_right, split_clusters, visual_order};
|
||||
#[cfg(test)]
|
||||
use metadata::{civil, parse_iso};
|
||||
pub use options::{PdfError, PdfOptions};
|
||||
|
||||
@@ -2450,3 +2450,15 @@ fn cluster_ends_match_the_next_larger_cluster_in_linear_time() {
|
||||
assert!(ends[..=n].iter().all(|e| *e == 2 * n + 2));
|
||||
assert_eq!(ends.last(), Some(&(2 * n + 4)));
|
||||
}
|
||||
|
||||
/// A run's baseline is the y most of its glyphs share, ties to the lower bits; a run on one
|
||||
/// baseline (the common case) gives its y without counting.
|
||||
#[test]
|
||||
fn baseline_is_the_most_shared_y() {
|
||||
assert_eq!(baseline(&[]), None);
|
||||
assert_eq!(baseline(&[(1, 0.0, 7.5)]), Some(7.5));
|
||||
assert_eq!(baseline(&[(1, 0.0, 7.5), (2, 3.0, 7.5), (3, 6.0, 7.5)]), Some(7.5));
|
||||
assert_eq!(baseline(&[(1, 0.0, 7.5), (2, 3.0, 4.0), (3, 6.0, 7.5)]), Some(7.5));
|
||||
assert_eq!(baseline(&[(1, 0.0, 9.0), (2, 3.0, 4.0)]), Some(4.0));
|
||||
assert_eq!(baseline(&[(1, 0.0, -0.0), (2, 3.0, 0.0)]), Some(0.0));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user