Files
designcraft/crates/pdf/src/text.rs
T
aiongg 18ae8e4761 Merge upstream main a184db6f8 with our 15 PRs (batch-0) into next
Conflicts resolved per file (both sides kept unless noted):
crates/engine/src/atomic.rs | 1 | main's: `write_file` removed (main routes command writes through `crate::io::write_file`); its one caller in file.rs now uses `crate::io::write_file`
crates/engine/src/cmd/datamerge/fill.rs | 1 | next's `Graphic` literal (has `clip` and `wrap`, which next's `Graphic` carries)
crates/engine/src/cmd/datamerge/mod.rs | 1 | main's `read_path_capped` (crate::io metadata/read_capped, roots checked); next's capped read is the same behaviour
crates/engine/src/cmd/datamerge/parse.rs | 1 | main's (`crate::io::metadata`, `crate::io::Metadata`)
crates/engine/src/cmd/edit.rs | 1 | next's `spread_param(p, "edit.paste")` (the second argument is the command id on both sides)
crates/engine/src/cmd/export.rs | 3 | main's: EPUB params doc, `toc_title` once (main's position; next's duplicate dropped)
crates/engine/src/cmd/file.rs | 7 | place doc: next's (lists page/rect/autoflow params #803 checks); next's recent-file functions + main's `foreign_document`; next's open (load_bytes, warnings, non-DesignCraft refusal) with main's checked `crate::io::read_file` for the bytes; main's `check_write` be
crates/engine/src/cmd/find.rs | 7 | main's Find scope (FindContext, scope/selection/story, continuation, UTF-8 repair) on next's `designcraft_compose::grep::Grep` (dialect GREP): find uses `find_all` capped at MAX_MATCHES, change uses `replacements`/`find_all` (literal change text not expanded), fin
crates/engine/src/cmd/fonts.rs | 1 | next's (`has_style`, resolved/replacement face); `has_style` already compares styles ignoring spacing and case, which is main's `same_style` fix; main's test kept
crates/engine/src/cmd/interchange.rs | 1 | next's `import_idml_report_with_previews` (previews for unreadable links)
crates/engine/src/cmd/layout.rs | 8 | pages.insert: main's overflow-safe `after` + next's checked `parent_ref`; main's duplicateSpread doc; layout.section: next's `SectionEdit` (already keeps unnamed settings, #717) with main's `d.repaginate()` moved into `SectionEdit::apply` (remove and set paths),
crates/engine/src/cmd/links.rs | 5 | next's links.list (no doc clone); main's `text_status` via crate::io; `use std::io::Read` dropped (unused after main's read_capped); test literals main's. `link_missing` (next) uses `crate::io::exists` for the file-guard scan
crates/engine/src/cmd/object.rs | 4 | object.fill/stroke tint-alone: main's (23e1680e7; out-of-range tint is an error, which main's test asserts); object.fit doc: next's (lists `axis`)
crates/engine/src/cmd/paths.rs | 1 | next's (shatai transform on outlines)
crates/engine/src/cmd/place_text.rs | 1 | next's `add_flow_frame` (used by autoflow)
crates/engine/src/cmd/preflight.rs | 2 | next's overset/unplacedStory issue (its `overset_location` already applies the parent transform main's #738 fix adds); next's two test modules kept with main's
crates/engine/src/cmd/scripts.rs | 1 | combined: next's `in_undo_step = false` (#406) + main's full rollback of a failed script (#709)
crates/engine/src/cmd/style.rs | 14 | param docs next's (gradient swatch.create, swatch stops, model colours); create_para next's; rename_style main's structure (rename entry, then `redirect_style_refs`), redirect has next's full reference set (cells, defaults, notes, TOC) + main's never-based-on-it
crates/engine/src/cmd/text.rs | 10 | main's selectSentence/Paragraph/Story + select_around; next's selectionAttrs (mixed) + main's openTypeFeatures/type.feature; main's comments; tracked typing: next's `reserve_command_text` + main's `insert_format_at`; empty-paragraph mark formatting main's (with `
crates/engine/src/cmd/thread.rs | 1 | next's `spread_param(p, "text.thread")`
crates/engine/src/dtd.rs | 1 | next's linear, depth-bounded `expand_parameter_entities` with main's budget (input + 1 MiB, `MAX_EXPANDED_BYTES`) and message ("expand the DTD beyond"); 16 MiB constant removed; both sides' tests pass on message
crates/engine/src/guard.rs | 2 | main's whole-transaction snapshot (docs, active, journal; #742) + next's `in_undo_step` restore; tests both
crates/engine/src/lib.rs | 5 | LoadedGraphic doc next's; next's `new_document_preview`; execute: main's `discard_stale_find_scope()` before next's nested-undo run; modules both (tests_autosize, tests_file_guard, tests_params, tests_roots)
crates/engine/src/recovery.rs | 1 | next's test (superset of main's assertion)
crates/engine/src/tests.rs | 4 | moved tests deduplicated (identical on both sides); next's preflight and direct-selection/kerning tests + main's style-delete, locked-frame and baseline-fit tests
crates/engine/src/tests_idml.rs | 3 | main's order (drop cap and margins tests identical on both sides, once each)
crates/ui-egui/src/canvas.rs | 3 | next's screen_options/DisplayKey block and tests kept; two tests that auto-merge had added twice (parent_guides_on_a_page_are_not_picked_there, baseline_grid_lines_are_the_ones_on_the_page) kept once; main's `typing`/`screen_at`/`click` helpers and its tests clicks
crates/ui-egui/src/control.rs | 3 | main's `count` check and pending budget (control queue + synthetic queue); exact-budget test runs both sides' cases (click 255 on 9,492 pending; drag 9492×255)
crates/ui-egui/src/cursors.rs | 1 | main's (same Pen cursor set)
crates/ui-egui/src/dialogs.rs | 14 | dialog body (newDocument … alert match) is next's text, with main's Find/Change scope reset (`set_field`, `reset_find_scope`, `find_change_params`) and main's Create Guides / Margins and Columns arms ported in; `Dialog::new` ruby section (next) + main's `set_fi
crates/ui-egui/src/i18n.rs | 7 | ka and pl modules, languages, statics and lookups both; `category_first` covers uk, ru, pl, ka and `count_label` keeps next's Czech plurals; shared TABLE union; next's duplicate rows for Auto, Number:, Gutter:, Top:, Bottom:, Inside:, Outside:, Left:, Right: dropped
crates/ui-egui/src/i18n/ar.rs | 2 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/it.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/ja.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/pt_br.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/uk.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/icons.rs | 2 | both tests (main's typography icon, next's distribute icons)
crates/ui-egui/src/lib.rs | 11 | UiState: next's tabs_panel + main's last_color/last_gradient; `cursors` field once; next's SVG paste and paste-on-spread-in-view; next's extensionless document open; opens_as_document next's comment; tests both (next's control bar / key-name tests, main's rich black
crates/ui-egui/src/menus.rs | 15 | app.language doc next's text with pl and ka; UI_COMMANDS both (next's Tabs, Toggle Presentation, Apply None text; main's panel commands, Apply Color, Apply Gradient); Edit › Interface Language stays out (next moved it to Preferences); Type menu main's order with
crates/ui-egui/src/panels/glyphs.rs | 1 | next's (already has main's scroll-bar fix); the scroll-bar test module auto-merge had added twice kept once
crates/ui-egui/src/panels/layers.rs | 3 | next's drag-and-drop `object_rows`, with main's features ported into it: selection reveal (layer opens, row scrolls into view once), selected rows highlighted (also a group whose member is selected), double-click rename in place (`object.rename` queued like
crates/ui-egui/src/panels/mod.rs | 6 | tabs + typography modules and color_panel_tests; SelInfo has wrap_side/wrap_contour and wrap_offsets (offsets from the graphic's own wrap when it is selected); next's swatch_picker returning the chip response; next's content_selection_tests
crates/ui-egui/src/panels/pages.rs | 1 | next's (parents dragged by id)
crates/ui-egui/src/panels/properties.rs | 4 | next's swatch popup (Apply To, Tint); main's four wrap offsets then next's Wrap To / Contour Type; test fixtures main's (`..Default::default()`, no unused builder)
crates/ui-egui/src/panels/swatches.rs | 3 | main's Delete Swatch button + next's New Swatch comment; next's draggable rows; click: main's PICKED + fill/stroke in front, with next's no-apply on double-click
crates/ui-egui/src/toolbar.rs | 2 | main's Apply Color / Gradient / None wells and functions; next's apply_none doc line
crates/compose/src/lib.rs | 5 | next's line loop kept (mark_leading + last_line_mark, wrap segments/carry, grid gyoudori); main's mark-chars base_leading block (auto-merged) stays beside it. `fits`: next's #106 rule (baseline fit for horizontal frames, full descent for paths, vertical text, footnote
crates/compose/src/tests.rs | 5 | next's tests (tombstone set, sliver/one-sided/contour/clipping wrap tests, borders, lists, kenten, grid, anchored wrap, balance, shaped frames) plus main's an_empty_last_paragraph_isnt_overset; main's wrap_object_beside_one_narrow_column_leaves_the_other_alone re-ad
crates/doc/src/assets.rs | 1 | main's doc comment (same meaning)
crates/doc/src/build.rs | 3 | parent_page_for: next's parent_page_for_side (RTL-aware); single_sided_page_shows_first_parent_page test kept once, at main's position
crates/doc/src/item.rs | 1 | next's rotated_group_bounds_follow_the_children_outlines test kept
crates/doc/src/lib.rs | 1 | next's kerning_increment field kept; main's "Bulleted & Numbered" wording
crates/doc/src/page.rs | 1 | next's outer_page_indices + parent_page_for_side (integration of parent-margins with rtl-master-sides); main's parent_page_index dropped, its only caller (build.rs) uses parent_page_for_side
crates/doc/src/slice.rs | 5 | extract: next's `marked()` (get-based, fix_notes/fix_endnotes/fix_marks; main's version indexed slices and could panic); insert_story: main's take() limit on endnote/editorial payload copies with next's id comment, redundant `let ne`/`let nn` dropped; main's excess_note
crates/geom/src/corners.rs | 1 | next's box_outline_rounds_only_closed_corners test kept
crates/render/src/damage.rs | 1 | next's wrapping-anchored-object damage test kept
crates/render/src/fx.rs | 2 | feather: next's choke/corners sigma+join inside main's structure (body and clip at device resolution, with_filters(.., None)); inner glow centre: next's blend `mix` with main's clip argument (bp, xf)
crates/render/src/lib.rs | 3 | next's layer comment; next's front baseline grid draw after items; gradient match on g.kind (main's tuple scrutinee did not match the single-pattern arms)
crates/render/src/text.rs | 5 | paint_glyphs: main's paint_stroke closure structure with next's stroke overprint (overprinting helper, stroke `(TextPaint, bool)`); callers pass next's overprint_fill/overprint_stroke
crates/render/tests/placed_pdf.rs | 1 | main's `near` helper version
crates/tools/src/lib.rs | 1 | main's (PenConvert already declared below, avoids a duplicate variant)
crates/tools/src/pen.rs | 1 | main's module doc
crates/tools/src/select.rs | 1 | next's Drag::Corner variant (live corners) kept
crates/tools/src/tests.rs | 4 | next's tests (content drag, live corners, table border drag, eyedropper matchFrom, fitting handles, pen cursors once); eyedropper_ignores_inactive keeps next's samples_image check (main's exact-JSON compare lacks matchFrom) with main's `..Default::default()` Asset lit
crates/tools/src/text.rs | 2 | next's table border drag (BorderDrag, border_at) kept, then main's comment on locked text frames
crates/tools/src/xform.rs | 2 | main's explicit `Vec<&Item>` for shown children; next's eyedropper matchFrom (integration 796e9d713)
Cargo.toml | 2 | main's craft-pdf workspace dep added (craft-print stays once, in next's craft-libs block); [patch.crates-io] keeps next's winit (rust-windowing #4694 backport) and adds main's krilla patch; main's skylartaylor winit left out (see UNSURE 1)
Cargo.lock | 1 | union: next's craft-atomic and craft-cms entries plus main's craft-pdf entry; needs `cargo` normalisation
README.md | 1 | Open bullet combines INDD import via indd-utils (next) and editable PowerPoint export (main); main's paragraph break kept
contributors/people.toml | 1 | both people kept (switch87 from next, Nmarello from main)
apps/designcraft-cli/src/main.rs | 4 | next's `Export` enum dispatch kept; main's .pptx export added as `Export::Pptx` (run prints slides and warnings, script runs file.exportPptx); main's `--pdf-options` null-as-{} parse; usage lines list .pptx
apps/designcraft/src/main.rs | 3 | `startup.leave()` right after `app.ui` (main #380) and next's macOS `sync_movable` kept after the IME discard; next's Apple Events install kept; main's macOS Dock Quit `with_close_requested_on_terminate` block left out (needs the skylartaylor winit; UNSURE 1)
crates/fonts/src/fontdb.rs | 9 | FONT6 aliases (next) combined with #911 WWS names (main): ScannedFace and both CatalogEntry constructors carry `wws` and `aliases`; `canonical_family` falls back to WWS family names; `exact_style_traits` canonicalises then tries typographic, then WWS names (`named_st
crates/format/src/lib.rs | 1 | tests: both sides kept (next's r7a nullable-default tests; main's oversized ZIP entry/asset/entry-count tests)
crates/idml/src/import.rs | 11 | main's bounded `Package` reader under next's `import_idml_report_with_previews` (metadata read through `pkg.part`); next's `tab_list_repaired` kept; ruler origin: next's `settings.ruler_origin`, and main's guide import reads it (main's private `RulerOrigin` enum and
crates/idml/src/tests.rs | 6 | item-level union: all next tests plus main's new ones (ruler guides, package limits, CJK leading model, liquid page rules module, mojikumi); no test dropped
crates/mcp/src/backend.rs | 1 | test comment merged from both
crates/pdf/src/tests.rs | 1 | tests: both sides kept
crates/pdf/src/text.rs | 4 | next's (skew `tan`, cluster `start`, ruby overprint warning); main had no change here beyond base
crates/textimport/src/docx.rs | 5 | next's (ListCounter integration for list levels, page-break test position); main's side was the older form of the same code
crates/epub/src/lib.rs | 30 | non-test code is next's (StoryWriter holds img_src/images/no_alt/heading_ids; covers main's #787/#791/#782 fixes, plus #314 group transforms, anchored images in images_without_alt, endnote story skipped in the TOC); tests: next's set plus main's extra css_weight_style a
docs/control-protocol.md | 2 | next's rows (ui.paste, count 1–255, documentGrid) and wording
docs/file-format-parity.md | 1 | rows combined (next's ICC notes, main's TIFF indexed colour)
docs/gaps.md | 2 | status change notes unioned; Flex Layout removed from missing (main implemented it), next's Parent Options sentence kept
docs/localization-parity.md | 4 | Polish and Georgian rows; 5 languages beyond the twelve; file list includes cs and ka; both history rows
docs/roadmap.md | 1 | M1 next's (#186 fixed on next), M2 main's IME wording
docs/target-app-parity.md | 6 | next's status line; File formats row next's, UI/UX row main's (IME no longer failing); UI line next's plus main's Swatches detail; one Page tool line (main's fuller one); Load styles and main's fuller Pathfinder line both kept, main's duplicate short Pathfinder line r
docs/ui-parity.md | 1 | IME row main's, Font menu row next's

i18n/fr.rs: five rows dropped that main's shared table now has (Row, Open Path, Flex Layout, Current Page Number, Rulers).
2026-10-16 03:09:22 +00:00

641 lines
28 KiB
Rust

//! Composed text → PDF text objects with embedded (subsetted) fonts.
//!
//! 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. Right-to-left lines and runs carry no
//! ActualText of their own: poppler orders right-to-left glyphs by itself, but it positions an
//! ActualText with the transform in force where its span ends (losing a span over several runs)
//! and reverses the characters of one in a right-to-left line.
use std::collections::HashMap;
use std::ops::Range;
use std::sync::Arc;
use craft_pdf::write::backend as krilla;
use designcraft_compose::{ComposedStory, FrameText, PlacedGlyph};
use designcraft_fonts::{FontDb, FontFace};
use designcraft_geom::kurbo::Shape as _;
use designcraft_geom::{Affine, BezPath, Rect};
use krilla::geom::Point;
use krilla::num::NormalizedF32;
use krilla::paint::{Fill, FillRule, Paint, Stroke};
use krilla::surface::Surface;
use krilla::tagging::{Artifact, ArtifactType, ContentTag, SpanTag};
use krilla::text::{Font, GlyphId, KrillaGlyph};
use crate::export::{Exporter, solid_fill, tf, to_path};
impl Exporter<'_> {
/// A text run's paint: solid, or a gradient spanning the frame's box (`area`, text space),
/// for glyphs drawn under `origin` (text space ← run space).
fn text_paint(&mut self, swatch: &str, tint: f32, origin: Affine, area: Rect) -> Option<Paint> {
if designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch).is_some() && origin.determinant().abs() > 1e-12 {
let fill = designcraft_doc::Fill { swatch: swatch.into(), tint, ..designcraft_doc::Fill::none() };
return self.fill_paint_in(&fill, self.text_area.unwrap_or(area), origin.inverse());
}
self.swatch_color(swatch, tint).map(Into::into)
}
}
/// The box a gradient on a cell's or a note's text spans: its lines, each padded by its ascent as
/// the screen renderer pads them, so both lay the gradient out alike.
fn lines_area(ft: &FrameText) -> Option<Rect> {
ft.lines
.iter()
.filter_map(|l| {
let first = l.glyphs.first()?;
let x0 = l.glyphs.iter().map(|g| g.x).fold(first.x, f64::min);
let x1 = l.glyphs.iter().map(|g| g.x + g.adv.max(0.0)).fold(l.end_x, f64::max);
let pad = l.ascent.max(1.0);
let bottom = l.drop_cap.map_or(l.baseline + l.descent.max(0.0), |dc| dc.rect.y1.max(l.baseline + l.descent.max(0.0)));
Some(Rect::new(x0 - pad, l.baseline - l.ascent - pad, x1 + pad, bottom + pad))
})
.reduce(|a, b| a.union(b))
}
/// An opaque nonzero fill with `paint`.
fn paint_fill(paint: Paint) -> Fill {
Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }
}
/// Private-use characters DesignCraft stores for markers (page numbers, section markers, breaks).
fn is_marker(c: char) -> bool {
('\u{E000}'..='\u{E1FF}').contains(&c)
}
/// Can `b` join the run `a` is in? A missing glyph (.notdef) is a run of its own.
fn same_run(a: &PlacedGlyph, b: &PlacedGlyph) -> bool {
b.visible
&& a.gid != 0
&& b.gid != 0
&& a.face.id() == b.face.id()
&& a.style == b.style
&& (a.sx - b.sx).abs() < 1e-9
&& (a.sy - b.sy).abs() < 1e-9
}
/// 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.
fn text_rules(cs: &ComposedStory, ft: &FrameText, underline: bool) -> Vec<(designcraft_compose::Rule, Rect)> {
let mut bars: Vec<(designcraft_compose::Rule, Rect)> = Vec::new();
for line in &ft.lines {
for g in line.glyphs.iter().filter(|g| g.visible) {
let style = &cs.styles[g.style as usize];
let (on, rule) = if underline { (style.underline, &style.underline_rule) } else { (style.strikethrough, &style.strike_rule) };
if !on {
continue;
}
let rect = rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, line, g));
match bars.last_mut() {
Some((last_rule, last)) if last_rule == rule && (last.y0 - rect.y0).abs() < 1e-6 && (rect.x0 - last.x1).abs() < 0.5 => {
last.x1 = rect.x1;
}
_ => bars.push((rule.clone(), rect)),
}
}
}
bars
}
impl Exporter<'_> {
pub(crate) fn font(&mut self, face: &FontFace) -> Option<Font> {
self.fonts
.entry(face.id())
.or_insert_with(|| {
// Keep the existing outline fallback and warning when embedding fails.
let variations = face.is_variable().then_some(face.coords.as_slice());
craft_pdf::write::font(face.data(), face.index(), variations)
.inspect_err(|error| log::warn!("PDF font `{} {}` could not be embedded: {error}", face.family, face.style))
.ok()
})
.clone()
}
fn reverse_cmap(&mut self, face: &FontFace) -> Arc<HashMap<u32, char>> {
self.reverse_cmaps
.entry(face.id())
.or_insert_with(|| {
let mut m = HashMap::new();
// Sorted by code point: the lowest code point wins (`-` before U+2010).
for (c, g) in face.chars() {
m.entry(g).or_insert(c);
}
Arc::new(m)
})
.clone()
}
/// Draw `f` as an artifact while a story is being tagged (nested text isn't tagged).
fn as_artifact(&mut self, s: &mut Surface, f: impl FnOnce(&mut Self, &mut Surface)) {
match self.tag_story.take() {
Some(sid) => {
s.start_tagged(ContentTag::Artifact(Artifact::new(ArtifactType::Other, None)));
f(self, s);
s.end_tagged();
self.tag_story = Some(sid);
}
None => f(self, s),
}
}
/// A glyph run, tagged under its paragraph while a story is being tagged.
fn tagged_run(&mut self, s: &mut Surface, cs: &ComposedStory, gs: &[PlacedGlyph], baseline: f64, story: &str) {
match self.tag_story {
Some(sid) => {
let pi = self.doc.story(sid).map_or(0, |st| st.para_at(gs[0].byte.min(st.len())));
let id = s.start_tagged(ContentTag::Span(SpanTag::empty()));
self.run(s, cs, gs, baseline, story);
s.end_tagged();
self.para_tags.entry((sid, pi)).or_default().push(id);
}
None => self.run(s, cs, gs, baseline, story),
}
}
pub(crate) fn frame_text(&mut self, s: &mut Surface, cs: &ComposedStory, ft: &FrameText, story: &str) {
// Paragraph shading and rules under the text.
self.as_artifact(s, |me, s| {
for d in ft.decos.iter().filter(|d| !d.nonprinting) {
if let Some(c) = me.swatch_color(&d.color, d.tint) {
match &d.path {
Some(p) => me.fill_path(s, p, c),
None => me.fill_rect(s, d.rect, c),
}
}
}
if !ft.tables.is_empty() {
me.tables(s, ft);
}
});
// All underlines precede all glyph runs, including ink overlapping from another run.
let underlines = text_rules(cs, ft, true);
if !underlines.is_empty() {
self.as_artifact(s, |me, s| {
for (rule, r) in underlines {
if let Some(c) = me.swatch_color(&rule.color, rule.tint) {
me.fill_rect(s, r, c);
}
}
});
}
for l in &ft.lines {
let gs = &l.glyphs;
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;
}
// Vertical type: an upright glyph turns back a quarter about its em box centre.
// Shatai slants each glyph about its own em box centre first.
let turn = if ft.vertical { g.vertical_xf(l.baseline) } else { None };
let shatai = cs.styles.get(g.style as usize).and_then(|st| st.shatai_xf(g, l.baseline));
if turn.is_some() || shatai.is_some() {
let m = turn.unwrap_or(Affine::IDENTITY) * shatai.unwrap_or(Affine::IDENTITY);
s.push_transform(&crate::export::tf(m));
self.tagged_run(s, cs, &gs[i..i + 1], l.baseline, story);
s.pop();
i += 1;
continue;
}
let j = run_end(ft, gs, i);
self.tagged_run(s, cs, &gs[i..j], l.baseline, story);
i = j;
}
}
self.as_artifact(s, |me, s| {
for (rule, r) in text_rules(cs, ft, false) {
if let Some(c) = me.swatch_color(&rule.color, rule.tint) {
me.fill_rect(s, r, c);
}
}
for n in &ft.notes {
if let Some(nft) = n.text.frames.first() {
s.push_transform(&tf(Affine::translate(n.origin.to_vec2())));
let area = std::mem::replace(&mut me.text_area, lines_area(nft));
me.frame_text(s, &n.text, nft, &n.source);
me.text_area = area;
s.pop();
}
}
});
}
/// 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 {
for c in &t.cells {
if let Some((sw, tint)) = &c.fill
&& let Some(col) = self.swatch_color(sw, *tint)
{
self.fill_rect(s, c.rect, col);
}
}
for c in &t.cells {
if let Some(g) = &c.graphic {
if let Some(clip) = to_path(&c.clip.to_path(0.1)) {
s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero);
s.push_transform(&tf(Affine::translate((c.clip.x0, c.clip.y0))));
self.graphic(s, g);
s.pop();
s.pop();
}
} else if let Some(cft) = c.text.frames.first() {
s.push_transform(&tf(Affine::translate(c.origin.to_vec2())));
let area = std::mem::replace(&mut self.text_area, lines_area(cft));
self.frame_text(s, &c.text, cft, &c.source);
self.text_area = area;
s.pop();
}
}
for seg in &t.strokes {
let st = &seg.stroke;
let Some(col) = self.swatch_color(&st.color, st.tint) else { continue };
let kind = self.doc.stroke_kind(&st.kind);
let mut bp = BezPath::new();
bp.move_to(seg.a);
bp.line_to(seg.b);
let Some(p) = to_path(&bp) else { continue };
// Butt-ended fills close the table's corners the way square caps do.
let (ca, cb) = t.corner_capped(seg);
let mut capped = BezPath::new();
capped.move_to(ca);
capped.line_to(cb);
let Some(cp) = to_path(&capped) else { continue };
// Gap colour under dashes, dots and stripes: the whole edge, solid.
if !matches!(kind, designcraft_doc::StrokeType::Solid)
&& st.gap_color != designcraft_color::swatch::NONE
&& let Some(gap) = self.swatch_color(&st.gap_color, st.gap_tint)
{
s.set_fill(None);
s.set_stroke(Some(Stroke {
paint: gap.into(),
width: st.weight as f32,
line_cap: krilla::paint::LineCap::Butt,
..Default::default()
}));
s.draw_path(&cp);
s.set_stroke(None);
}
// Stripes, wavy and hash types are fills along the edge.
if let Some(o) = kind.outline(&capped, st.weight, 0.05)
&& let Some(op) = to_path(&o)
{
s.set_stroke(None);
s.set_fill(Some(solid_fill(col, 1.0)));
s.draw_path(&op);
s.set_fill(None);
continue;
}
let (cap, dash) = match &kind {
designcraft_doc::StrokeType::Dashed { pattern }
if designcraft_doc::StrokeType::expandable_dashes(pattern, seg.a.distance(seg.b)) =>
{
let mut pat: Vec<f32> = pattern.iter().map(|v| v.max(0.0) as f32).collect();
if pat.len() % 2 == 1 {
pat.extend(pat.clone());
}
(krilla::paint::LineCap::Butt, Some(krilla::paint::StrokeDash { array: pat, offset: 0.0 }))
}
designcraft_doc::StrokeType::Dotted
if designcraft_doc::StrokeType::expandable_dashes(&[0.0, st.weight * 2.0], seg.a.distance(seg.b)) =>
{
(krilla::paint::LineCap::Round, Some(krilla::paint::StrokeDash { array: vec![0.0, (st.weight * 2.0) as f32], offset: 0.0 }))
}
_ => (krilla::paint::LineCap::Square, None),
};
s.set_fill(None);
s.set_stroke(Some(Stroke { paint: col.into(), width: st.weight as f32, line_cap: cap, dash, ..Default::default() }));
s.draw_path(&p);
s.set_stroke(None);
}
}
}
fn fill_rect(&self, s: &mut Surface, r: Rect, c: krilla::color::Color) {
self.fill_path(s, &r.to_path(0.1), c);
}
fn fill_path(&self, s: &mut Surface, bp: &designcraft_geom::BezPath, c: krilla::color::Color) {
if let Some(p) = to_path(bp) {
s.set_stroke(None);
s.set_fill(Some(solid_fill(c, 1.0)));
s.draw_path(&p);
s.set_fill(None);
}
}
/// Unicode text for a run's glyphs: (text, per-glyph byte range into it).
fn run_text(&mut self, glyphs: &[PlacedGlyph], story: &str) -> (String, Vec<Range<usize>>) {
let cmap = self.reverse_cmap(&glyphs[0].face);
let mut text = String::new();
let mut ranges: Vec<Range<usize>> = Vec::with_capacity(glyphs.len());
// Byte of the previous glyph when it came straight from the story (cluster continuation).
let mut prev_src: Option<usize> = None;
for g in glyphs {
let src = story.get(g.byte..g.byte + g.len).filter(|t| !t.is_empty() && !t.chars().any(is_marker));
let range = if let Some(t) = src {
prev_src = Some(g.byte);
let a = text.len();
text.push_str(t);
a..text.len()
} 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 let Some(t) = g.generated_text.as_deref().filter(|t| !t.is_empty() && !t.chars().any(is_marker)) {
// Generated text (page numbers, list labels): its own characters, which a glyph
// the font substituted (small-cap or old-style figures) can't be mapped back to.
prev_src = None;
let a = text.len();
text.push_str(t);
a..text.len()
} else if g.generated_text.as_deref() == Some("") && !ranges.is_empty() {
// Later glyph of a generated cluster.
prev_src = None;
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 {
// Other inserted glyphs (hyphens, tab leaders, ruby): map back through the font.
prev_src = None;
match cmap.get(&g.gid) {
Some(c) => {
let a = text.len();
text.push(*c);
a..text.len()
}
None => match ranges.last() {
Some(r) => r.clone(),
None => {
text.push('\u{FFFD}');
0..text.len()
}
},
}
};
ranges.push(range);
}
(text, ranges)
}
fn run(&mut self, s: &mut Surface, cs: &ComposedStory, glyphs: &[PlacedGlyph], baseline: f64, story: &str) {
let g0 = &glyphs[0];
let st = &cs.styles[g0.style as usize];
let face = g0.face;
let size = g0.sy * face.upem;
let hs = g0.sx / g0.sy;
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);
// Outside a frame (no text area), a gradient spans the run.
let end = glyphs.last().map_or(g0.x, |g| g.x + g.adv);
let run_area = Rect::new(g0.x, baseline - size, end.max(g0.x + 1.0), baseline + size * 0.25);
let fill = self.text_paint(&st.fill, st.fill_tint, origin, run_area);
let stroke = if st.stroke != designcraft_color::swatch::NONE && st.stroke_weight > 0.0 {
self.text_paint(&st.stroke, st.stroke_tint, origin, run_area)
} else {
None
};
if fill.is_none() && stroke.is_none() {
return;
}
if st.overprint_fill || st.overprint_stroke {
self.warn("overprint is not exported yet");
}
// Stroke behind fill: a double-width stroke first, as outlines so the text is not
// duplicated, then the fill over its inner half. With no fill, the stroke is clipped
// to the outside of the glyphs instead.
let behind = st.stroke_behind_fill && stroke.is_some();
let weight = if behind { st.stroke_weight * 2.0 } else { st.stroke_weight };
let mut clipped = false;
let stroke = if behind {
let outlines = glyph_outlines(glyphs, size, tan);
if fill.is_some() {
self.paint_outlines(s, &outlines, origin, None, stroke, weight);
None
} else {
clipped = self.push_outside_clip(s, &outlines, origin, weight);
stroke
}
} else {
stroke
};
// A missing glyph is drawn as its box: PDF/A and PDF/UA forbid showing .notdef as text.
if g0.gid == 0 {
self.paint_outlines(s, &glyph_outlines(glyphs, size, tan), origin, fill, stroke, weight);
} else if let Some(font) = self.font(&face) {
self.text_run(s, glyphs, story, font, origin, size, hs, tan, fill, stroke, weight);
} else {
self.warn(format!("font `{} {}` could not be embedded; its text was drawn as outlines", face.family, face.style));
self.paint_outlines(s, &glyph_outlines(glyphs, size, tan), origin, fill, stroke, weight);
}
if clipped {
s.pop();
}
}
/// A run as real text.
#[allow(clippy::too_many_arguments)]
fn text_run(
&mut self,
s: &mut Surface,
glyphs: &[PlacedGlyph],
story: &str,
font: Font,
origin: Affine,
size: f64,
hs: f64,
tan: f64,
fill: Option<Paint>,
stroke: Option<Paint>,
stroke_weight: f64,
) {
let Some(g0) = glyphs.first() else { return };
let (text, mut ranges) = self.run_text(glyphs, story);
let order = split_clusters(glyphs, &text, &mut ranges);
let k = 1.0 / (hs * size);
// 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()
.enumerate()
.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(paint_fill));
s.set_stroke(stroke.map(|paint| Stroke { paint, width: stroke_weight as f32, ..Default::default() }));
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();
}
/// Glyph outlines as paths (faces krilla can't embed, missing glyphs, strokes behind fill).
fn paint_outlines(
&mut self,
s: &mut Surface,
outlines: &BezPath,
origin: Affine,
fill: Option<Paint>,
stroke: Option<Paint>,
stroke_weight: f64,
) {
let Some(p) = to_path(outlines) else { return };
s.push_transform(&tf(origin));
s.set_fill(fill.map(paint_fill));
s.set_stroke(stroke.map(|paint| Stroke { paint, width: stroke_weight as f32, ..Default::default() }));
s.draw_path(&p);
s.set_fill(None);
s.set_stroke(None);
s.pop();
}
/// Clip to everything outside the glyphs (a frame around them plus their outlines,
/// even-odd). False when there was nothing to clip with.
fn push_outside_clip(&mut self, s: &mut Surface, outlines: &BezPath, origin: Affine, weight: f64) -> bool {
let m = weight * 2.0 + 10.0;
let mut outside = outlines.bounding_box().inflate(m, m).to_path(0.1);
outside.extend(outlines.iter());
let Some(p) = to_path(&(origin * outside)) else { return false };
s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd);
true
}
/// One line of plain text in the bundled UI face (page information mark).
pub(crate) fn plain_text(&mut self, s: &mut Surface, text: &str, at: (f64, f64), size: f64, color: krilla::color::Color) {
let face = FontDb::global().face(designcraft_fonts::FALLBACK_FAMILY, "Regular");
let Some(font) = self.font(&face) else { return };
let shaped = designcraft_fonts::shape(&face, text, &[], |c| c);
let upem = face.upem as f32;
let glyphs: Vec<KrillaGlyph> = shaped
.iter()
.enumerate()
.map(|(i, g)| {
let end = shaped.iter().skip(i + 1).map(|n| n.cluster).find(|c| *c != g.cluster).unwrap_or(text.len());
let end = if end > g.cluster { end } else { text.len() };
KrillaGlyph::new(
GlyphId::new(g.gid),
g.x_advance as f32 / upem,
g.x_offset as f32 / upem,
g.y_offset as f32 / upem,
0.0,
g.cluster..end,
None,
)
})
.collect();
s.set_stroke(None);
s.set_fill(Some(solid_fill(color, 1.0)));
s.draw_glyphs(Point::from_xy(at.0 as f32, at.1 as f32), &glyphs, font, text, size as f32, false);
s.set_fill(None);
}
}
/// A run's glyph outlines at `size`, relative to its first glyph's origin. A vertical offset (a
/// mark above or below its base) is undone by the skew `tan` so it lands where it was composed.
fn glyph_outlines(glyphs: &[PlacedGlyph], size: f64, tan: f64) -> BezPath {
let db = FontDb::global();
let mut bp = BezPath::new();
let Some(g0) = glyphs.first() else { return bp };
let k = size / g0.face.upem;
for g in glyphs {
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());
}
bp
}