Files
wordcraft/crates/layout/src/display.rs
T
3e20488c15 Comments: an option to show only the selected text's comment (#1055) (#1066)
* test(engine): drop a needless drop() that clippy 1.97 rejects (drop_non_drop)

* Comments: a click inside a selected balloon moves the caret in its text (#1013)

- The canvas under the markup area also "contains" the pointer, so a press on the balloon counted as a click on the page and left it
- Presses over a balloon card now belong to the card, in both the deselect check and the document click in mouse()
- Test: press, drag and release inside the selected balloon; a click on the page still leaves it

* Comments: an option to show only the selected text's comment (#1055)

- Track Changes Options › Comments: "In the margin" (default, as in Word) or "Only for the selected text"
- The second mode takes comments out of the margin column; the comment under the caret, or the clicked one, floats beside the page, over its edge in a narrow window
- The floating card has its own foreground layer, so presses on it never reach the canvas
- review.trackingOptions takes commentDisplay: margin|selected
- comment_range_at_caret: the comment whose range holds the caret
- Strings in all catalogs; tests for the option, the helper and the card's clicks

* vi: comment display options

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:55:27 -04:00

1654 lines
73 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Page → draw items, shared by the raster renderer, the PDF exporter and thumbnails.
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg, TextAlign};
use wordcraft_doc::para::{InlineObject, ShapeKind};
use wordcraft_doc::props::{Border, BorderStyle, Rgb, TextColor, TextDirection, Underline};
use wordcraft_doc::section::SectionStart;
use wordcraft_doc::{Block, Document, Path, StoryRef};
use wordcraft_fonts::{BezPath, FaceRef};
use wordcraft_geom::{Rect, Spin};
use crate::math::MItem;
use crate::para::{ClKind, LineEnd, ParaLayout};
use crate::{Page, Placed};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stroke {
Solid,
Dotted,
Dashed,
Double,
Wave,
}
#[derive(Clone, Debug)]
pub enum Draw {
Glyphs {
face: FaceRef,
size: f32,
/// Glyph id, x, baseline y (page coordinates).
glyphs: Vec<(u32, f32, f32)>,
color: Rgb,
alpha: f32,
synth_bold: bool,
synth_italic: bool,
/// The text these glyphs show (for PDF text extraction), and the hyperlink.
text: String,
link: Option<String>,
/// Byte range of `text` each glyph shows (one per glyph; a ligature's range covers all
/// its characters). Empty when unknown: the PDF writer then guesses from the font.
ranges: Vec<std::ops::Range<usize>>,
/// The text background (highlight or character shading) these glyphs are drawn on, if
/// any: a dark page dims it, so it picks the text colour against the dimmed colour.
text_bg: Option<Rgb>,
},
Fill {
rect: Rect,
color: Rgb,
alpha: f32,
/// A text background (highlight or character shading), which a dark page dims instead
/// of inverting so the text on it stays readable. Other fills (paragraph, cell, page) don't.
text_bg: bool,
},
Line {
x0: f32,
y0: f32,
x1: f32,
y1: f32,
width: f32,
color: Rgb,
stroke: Stroke,
alpha: f32,
/// The text background this line (an underline or strikethrough) is drawn on, as for
/// [`Draw::Glyphs`]; `None` for every other line.
text_bg: Option<Rgb>,
},
Image {
rect: Rect,
media: String,
crop: [f32; 4],
alpha: f32,
/// The picture's alt text (empty for a floating or diagram picture, which tag themselves).
alt: String,
},
Shape {
rect: Rect,
kind: ShapeKind,
fill: Option<Rgb>,
stroke: Option<Rgb>,
stroke_width: f32,
/// Shadow, glow and soft edges (drawn with [`wordcraft_doc::effects::bands`]).
effects: wordcraft_doc::effects::ShapeEffects,
/// A rounded rectangle's corner ([`wordcraft_doc::connector::ShapeExtra::corner`]).
corner: Option<u32>,
},
/// An ink stroke: a line through `pts` (page points) with round ends and joins, `width`
/// wide, in `color` at opacity `alpha`.
Ink { pts: Vec<(f32, f32)>, color: Rgb, width: f32, alpha: f32 },
/// A vector path (a chart's or diagram's polygons, slices, lines): filled, then stroked.
Path { segs: Vec<PathSeg>, fill: Option<Rgb>, stroke: Option<Rgb>, stroke_width: f32 },
/// An inline chart or diagram's draws, as one figure with its alt text (for tagged PDF).
Figure { alt: String, kind: GraphicKind, draws: Vec<Draw> },
/// A formatting mark (¶ · → ↵) in the UI's mark colour, or in `color` (a tracked
/// paragraph mark in its reviser's colour).
Mark { x: f32, baseline: f32, size: f32, ch: char, color: Option<Rgb> },
/// Turned text (a table cell's text direction): `items` are drawn in a frame turned `turn`
/// whose origin is page point (`x`, `y`) (see [`crate::turn_point`]).
Turned { x: f32, y: f32, turn: TextDirection, items: Vec<Draw> },
/// A rotated or flipped picture, shape, chart, group or Drawing Canvas: `items` drawn
/// turned by `spin` about page point (`cx`, `cy`) (see [`Spin::matrix`]).
Rotated { cx: f32, cy: f32, spin: Spin, items: Vec<Draw> },
/// `items` clipped to `rect` (a Drawing Canvas's members to its frame). Inside a
/// [`Draw::Rotated`], `rect` is in the turned frame's (unturned) coordinates.
Clip { rect: Rect, items: Vec<Draw> },
/// Text drawn from its outlines with WordArt effects (and bent, in a warped text box):
/// `path` in page coordinates, painted as [`crate::wordart::art_layers`] says. `color` is the
/// run's own colour, the fill when the effects set none.
Art { path: BezPath, fx: Box<wordcraft_doc::wordart::TextEffects>, color: Rgb, alpha: f32 },
/// A formatting-mark label (a section break's name) in the UI's mark colour, left edge at
/// `x`, drawn with the same face as [`Draw::Mark`]. Screen only, like every mark.
MarkText { x: f32, baseline: f32, size: f32, text: String },
}
impl Draw {
/// The affine map (a, b, c, d, e, f: x' = a·x + c·y + e, y' = b·x + d·y + f) from a frame
/// turned `turn` with its origin at page point (`x`, `y`) to the page.
pub fn turn_matrix(turn: TextDirection, x: f32, y: f32) -> [f32; 6] {
match turn {
TextDirection::Horizontal => [1.0, 0.0, 0.0, 1.0, x, y],
TextDirection::Down => [0.0, 1.0, -1.0, 0.0, x, y],
TextDirection::Up => [0.0, -1.0, 1.0, 0.0, x, y],
}
}
}
/// Formatting marks shown even while Show/Hide ¶ is off (File › Options › Display, "Always show
/// these formatting marks"). Show/Hide ¶ on shows them all.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct FormattingMarks {
/// Tab characters (→).
pub tabs: bool,
/// Spaces (·).
pub spaces: bool,
/// Paragraph marks (¶), line breaks, page, column and section breaks.
pub paragraphs: bool,
/// Hidden text (shown with its dotted underline instead of left out).
pub hidden: bool,
}
impl FormattingMarks {
pub const ALL: FormattingMarks = FormattingMarks { tabs: true, spaces: true, paragraphs: true, hidden: true };
/// The marks on screen: all of them with Show/Hide ¶ on, else the chosen ones.
pub fn shown(self, show_all: bool) -> FormattingMarks {
if show_all { FormattingMarks::ALL } else { self }
}
pub fn any(self) -> bool {
self.tabs || self.spaces || self.paragraphs || self.hidden
}
}
#[derive(Clone, Debug)]
pub struct DisplayOptions {
/// Show formatting marks (¶): Show/Hide ¶, every mark.
pub marks: bool,
/// Marks shown while `marks` is off (File › Options › Display).
pub always: FormattingMarks,
/// Dim headers/footers (editing the body) or the body (editing a header/footer).
pub dim_header: bool,
pub dim_body: bool,
/// Show tracked changes as markup (coloured, underlined/struck).
pub markup: bool,
/// Show on-screen-only marks: equation placeholders and prompts (never in print or PDF).
pub placeholders: bool,
/// Review › Hide Ink: leave ink strokes out (they stay in the document).
pub hide_ink: bool,
/// How tracked changes and comments are marked when `markup` is on (Track Changes Options).
pub revisions: MarkupOptions,
}
impl Default for DisplayOptions {
fn default() -> Self {
DisplayOptions {
marks: false,
always: FormattingMarks::default(),
dim_header: true,
dim_body: false,
markup: true,
placeholders: false,
hide_ink: false,
revisions: MarkupOptions::default(),
}
}
}
/// An option list with stable names for commands and saved preferences.
macro_rules! named {
($(#[$m:meta])* $name:ident { $($(#[$vm:meta])* $v:ident = $s:literal),+ $(,)? }) => {
$(#[$m])*
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum $name { $($(#[$vm])* $v),+ }
impl $name {
/// Every choice with its name, in menu order.
pub const ALL: &'static [($name, &'static str)] = &[$(($name::$v, $s)),+];
pub fn name(self) -> &'static str {
Self::ALL.iter().find(|(v, _)| *v == self).map(|(_, s)| *s).unwrap_or("")
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.iter().find(|(_, n)| *n == s).map(|(v, _)| *v)
}
}
};
}
named! {
/// How inserted text is marked.
InsertMark {
#[default]
Underline = "underline",
DoubleUnderline = "doubleUnderline",
Bold = "bold",
Italic = "italic",
Strikethrough = "strikethrough",
ColorOnly = "colorOnly",
None = "none",
}
}
named! {
/// How deleted text is marked.
DeleteMark {
#[default]
Strikethrough = "strikethrough",
DoubleStrikethrough = "doubleStrikethrough",
Hidden = "hidden",
Caret = "caret",
Hash = "hash",
Underline = "underline",
ColorOnly = "colorOnly",
}
}
named! {
/// Where the bar beside changed lines goes.
ChangeBar {
#[default]
Outside = "outside",
Left = "left",
Right = "right",
None = "none",
}
}
named! {
/// What the markup area beside the page shows.
BalloonMode {
/// Deletions, formatting and comments in balloons; insertions stay inline. Where the
/// editor shows balloons, deleted body text leaves the layout (`deletions_in_balloons`)
/// and the editor draws a "Deleted: …" balloon at its place; elsewhere (no markup area,
/// PDF, and headers, footers, notes and text boxes, which get no balloons) it is drawn
/// inline as in `CommentsAndFormatting`.
Revisions = "revisions",
/// Everything inline, no markup area.
Inline = "inline",
#[default]
CommentsAndFormatting = "commentsAndFormatting",
}
}
named! {
/// How comments are shown beside the page.
CommentDisplay {
/// Every comment in the margin, as in Word.
#[default]
Margin = "margin",
/// Only the comment of the text under the caret (or the clicked one) floats beside the page.
Selected = "selected",
}
}
/// Track Changes Options: what markup shows and how revisions are drawn. A per-user preference,
/// as in Word (saved with the interface settings, not the document).
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct MarkupOptions {
pub comments: bool,
pub ink: bool,
pub insertions_deletions: bool,
pub formatting: bool,
pub balloons: BalloonMode,
pub comment_display: CommentDisplay,
pub insert_mark: InsertMark,
/// `None` = by author.
pub insert_color: Option<Rgb>,
pub delete_mark: DeleteMark,
/// `None` = by author.
pub delete_color: Option<Rgb>,
pub changed_lines: ChangeBar,
/// `None` = automatic (dark grey).
pub changed_lines_color: Option<Rgb>,
/// Record formatting changes while tracking.
pub track_formatting: bool,
}
impl Default for MarkupOptions {
fn default() -> Self {
MarkupOptions {
comments: true,
ink: true,
insertions_deletions: true,
formatting: true,
balloons: BalloonMode::default(),
comment_display: CommentDisplay::default(),
insert_mark: InsertMark::default(),
insert_color: None,
delete_mark: DeleteMark::default(),
delete_color: None,
changed_lines: ChangeBar::default(),
changed_lines_color: None,
track_formatting: true,
}
}
}
impl MarkupOptions {
/// Tracked deletions take no room: they are hidden, or insertions and deletions aren't shown.
pub fn hides_deletions(&self) -> bool {
!self.insertions_deletions || self.delete_mark == DeleteMark::Hidden
}
}
/// Colour of moved text (both ends of a tracked move).
pub const MOVE_COLOR: Rgb = Rgb(0x1A, 0x7F, 0x37);
/// Colour for revisions by author index.
pub fn revision_color(i: u32) -> Rgb {
const C: [Rgb; 6] =
[Rgb(0xB0, 0x1E, 0x8F), Rgb(0x1E, 0x6E, 0xB0), Rgb(0x2E, 0x8B, 0x3E), Rgb(0xC0, 0x5A, 0x10), Rgb(0x70, 0x3C, 0xB0), Rgb(0x0E, 0x7C, 0x86)];
C.get(i as usize % C.len()).copied().unwrap_or(C[0])
}
fn stroke_of(u: Underline) -> Stroke {
match u {
Underline::Dotted => Stroke::Dotted,
Underline::Dash | Underline::DotDash | Underline::DotDotDash => Stroke::Dashed,
Underline::Double => Stroke::Double,
Underline::Wave | Underline::DoubleWave => Stroke::Wave,
_ => Stroke::Solid,
}
}
/// Draw items for a page.
pub fn page_display(doc: &Document, page: &Page, opts: &DisplayOptions) -> Vec<Draw> {
let mut out = Vec::new();
let ha = if opts.dim_header { 0.45 } else { 1.0 };
let ba = if opts.dim_body { 0.45 } else { 1.0 };
for (items, a) in [(&page.header, ha), (&page.footer, ha), (&page.items, ba)] {
let warps = warped_boxes(doc, items);
let mut done: Vec<u32> = Vec::new();
for it in items.iter() {
match it {
Placed::Lines { story: StoryRef::Part(id), .. } if warps.iter().any(|(w, ..)| w == id) => {
// A warped text box's lines are drawn together, bent, where its first one is.
if done.contains(id) {
continue;
}
done.push(*id);
if let Some((_, rect, warp)) = warps.iter().find(|(w, ..)| w == id) {
let lines: Vec<&Placed> =
items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(s), .. } if s == id)).collect();
warped_text(doc, &lines, *rect, warp, opts, a, &mut out);
}
}
_ => item(doc, it, opts, a, &mut out),
}
}
}
if opts.markup && (opts.revisions.insertions_deletions || opts.revisions.formatting) && opts.revisions.changed_lines != ChangeBar::None {
change_bars(doc, page, &opts.revisions, ba, &mut out);
}
out
}
/// One object drawn alone, as its page draws it: the picture, shape or group `o` in `rect` (its
/// unturned frame) and the text of the text box story `text_box` on `page`. A dragged object's
/// preview, which mustn't show what was behind it (#1010).
pub fn object_display(doc: &Document, page: &Page, o: &InlineObject, rect: Rect, text_box: Option<u32>, opts: &DisplayOptions) -> Vec<Draw> {
let mut out = Vec::new();
object_draws(o, rect, Spin::default(), 1.0, opts, &mut out);
let Some(id) = text_box else { return out };
for items in [&page.items, &page.header, &page.footer] {
let lines: Vec<&Placed> = items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(s), .. } if *s == id)).collect();
match warped_boxes(doc, items).into_iter().find(|(w, ..)| *w == id) {
Some((_, rect, warp)) if !lines.is_empty() => warped_text(doc, &lines, rect, &warp, opts, 1.0, &mut out),
_ => lines.into_iter().for_each(|it| item(doc, it, opts, 1.0, &mut out)),
}
}
out
}
/// The text boxes among `items` whose text is warped (Text Effects › Transform, drawn bent): their
/// story, frame and warp. Empty, without a walk of the document, when none is.
fn warped_boxes(doc: &Document, items: &[Placed]) -> Vec<(u32, Rect, wordcraft_doc::wordart::TextWarp)> {
items
.iter()
.filter_map(|it| {
let Placed::Object { rect, story, path, off, text_box: Some(id), .. } = it else { return None };
match doc.para(*story, path)?.object_at(*off)? {
InlineObject::Shape { extra, .. } => extra.warp.clone().filter(|w| !w.is_plain()).map(|w| (*id, *rect, w)),
_ => None,
}
})
.collect()
}
/// The text of a warped text box at `rect`: its lines' letters as outlines bent by `warp`, the
/// box's text stretched over the frame inside its margins (as WordArt is). Underlines, marks and
/// other decorations are left out; a warp that is drawn flat leaves the text as it is.
fn warped_text(
doc: &Document,
lines: &[&Placed],
rect: Rect,
warp: &wordcraft_doc::wordart::TextWarp,
opts: &DisplayOptions,
alpha: f32,
out: &mut Vec<Draw>,
) {
let mut raw = Vec::new();
for it in lines {
item(doc, it, opts, alpha, &mut raw);
}
// The letters: runs with effects as they are, plain runs from their glyphs.
let mut arts: Vec<(BezPath, Box<wordcraft_doc::wordart::TextEffects>, Rgb, f32)> = Vec::new();
let mut hidden = Vec::new();
for d in &raw {
match d {
Draw::Art { path, fx, color, alpha } => arts.push((path.clone(), fx.clone(), *color, *alpha)),
Draw::Glyphs { face, size, glyphs, color, alpha, synth_italic, .. } => {
if *alpha > 0.0 {
arts.push((crate::wordart::glyph_path(face, *size, glyphs, *synth_italic), Box::default(), *color, *alpha));
}
let mut g = d.clone();
if let Draw::Glyphs { alpha, .. } = &mut g {
*alpha = 0.0;
}
hidden.push(g);
}
_ => {}
}
}
let src = arts.iter().filter_map(|(p, ..)| crate::wordart::path_box(p)).reduce(|a, b| a.union(b));
let inset = Rect::new(rect.x + 7.2, rect.y + 3.6, (rect.w - 14.4).max(1.0), (rect.h - 7.2).max(1.0));
let fitted = src.and_then(|b| crate::wordart::Warp::new(warp, inset, (b.height() / b.width().max(1e-3)) as f32).map(|w| (b, w)));
let Some((src, w)) = fitted else {
out.extend(raw);
return;
};
for (path, fx, color, alpha) in arts {
out.push(Draw::Art { path: crate::wordart::warp_path(&path, src, &w), fx, color, alpha });
}
out.extend(hidden);
}
/// The automatic colour of the bars beside changed lines.
pub const CHANGE_BAR: Rgb = Rgb(0x50, 0x50, 0x50);
/// Bars in the margin beside body lines with tracked insertions or deletions, or (when shown)
/// tracked formatting changes of their text, paragraph or paragraph mark.
fn change_bars(doc: &Document, page: &Page, m: &MarkupOptions, alpha: f32, out: &mut Vec<Draw>) {
// Outside: the left margin, or the outer one of a right-hand page with mirrored margins.
let right = match m.changed_lines {
ChangeBar::Right => true,
ChangeBar::Outside => doc.settings.mirror_margins && page.number % 2 == 1,
_ => false,
};
let bx = if right { page.body.right() + 9.0 } else { page.body.x - 9.0 };
let color = m.changed_lines_color.unwrap_or(CHANGE_BAR);
for it in &page.items {
let Placed::Lines { story, path, para: pl, l0, l1, y, turn, .. } = it else { continue };
if turn.is_turned() {
continue;
}
let Some(first) = pl.lines.get(*l0) else { continue };
let (text, fmt) = (m.insertions_deletions, m.formatting);
let para = doc.para(*story, path);
let mark_rev = para.is_some_and(|p| (text && (p.mark.ins.is_some() || p.mark.del.is_some())) || (fmt && p.mark.fmt_change.is_some()));
// A paragraph whose own formatting changed is marked on every line.
let para_fmt = fmt && para.is_some_and(|p| p.props.fmt_change.is_some());
for li in *l0..*l1 {
let Some(line) = pl.lines.get(li) else { continue };
let changed = para_fmt
|| (line.c0..line.c1)
.filter_map(|k| pl.clusters.get(k).and_then(|c| pl.styles.get(c.style as usize)))
.any(|st| (text && (st.rc.ins.is_some() || st.rc.del.is_some())) || (fmt && st.rc.fmt.is_some()))
|| (mark_rev && line.end == LineEnd::Para);
if changed {
let top = y + (line.top - first.top);
out.push(Draw::Line {
x0: bx,
y0: top,
x1: bx,
y1: top + line.height,
width: 0.75,
color,
stroke: Stroke::Solid,
alpha,
text_bg: None,
});
}
}
}
}
fn border_stroke(s: BorderStyle) -> Stroke {
match s {
BorderStyle::Dotted => Stroke::Dotted,
BorderStyle::Dashed | BorderStyle::DotDash => Stroke::Dashed,
BorderStyle::Double | BorderStyle::Triple => Stroke::Double,
BorderStyle::Wave => Stroke::Wave,
_ => Stroke::Solid,
}
}
/// A border line from `(x0, y0)` to `(x1, y1)` (paragraph rules and character borders).
fn rule(x0: f32, y0: f32, x1: f32, y1: f32, b: &Border, alpha: f32) -> Draw {
Draw::Line {
x0,
y0,
x1,
y1,
width: if b.style == BorderStyle::Thick { b.width.max(1.5) } else { b.width.max(0.25) },
color: b.color.unwrap_or(Rgb::BLACK),
stroke: border_stroke(b.style),
alpha,
text_bg: None,
}
}
/// Closed box around one line segment of a character-border group, widened by `space`.
// Note: border drawn outside text advance, reserve width in line breaking if overlap shows.
fn char_box(b: &Border, x0: f32, x1: f32, top: f32, bottom: f32, alpha: f32, out: &mut Vec<Draw>) {
let (l, r) = (x0 - b.space, x1 + b.space);
out.push(rule(l, top, r, top, b, alpha));
out.push(rule(l, bottom, r, bottom, b, alpha));
out.push(rule(l, top, l, bottom, b, alpha));
out.push(rule(r, top, r, bottom, b, alpha));
}
fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mut Vec<Draw>) {
item_in(doc, it, opts, alpha, Spin::default(), out)
}
/// [`item`] drawn inside the turn `outer` (a turned Drawing Canvas's members): a shape's
/// effects follow all the turns it is drawn inside.
fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer: Spin, out: &mut Vec<Draw>) {
match it {
Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha, text_bg: false }),
Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)),
Placed::Image { rect, media, crop, spin, .. } => {
spun(*spin, *rect, vec![Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha, alt: String::new() }], out)
}
Placed::Shape { rect, kind, fill, stroke, stroke_width, effects, freeform, spin, extra } => {
let mut items = Vec::new();
let fx = effects_in(*effects, spin.within(outer));
shape_draws(*rect, *kind, *fill, *stroke, *stroke_width, fx, freeform.as_deref(), extra, opts, &mut items);
spun(*spin, *rect, items, out)
}
Placed::Graphic { rect, graphic, spin, .. } => spun(*spin, *rect, graphic_draws(doc, graphic, *rect, alpha), out),
Placed::Clip { rect, spin, items } => {
let mut inner = Vec::new();
for it in items {
item_in(doc, it, opts, alpha, spin.within(outer), &mut inner);
}
spun(*spin, *rect, vec![Draw::Clip { rect: *rect, items: inner }], out)
}
Placed::Cell { .. } | Placed::Object { .. } => {}
Placed::Lines { story, path, para, l0, l1, x, y, turn } if turn.is_turned() => {
let mut items = Vec::new();
lines(doc, *story, path, para, *l0, *l1, 0.0, 0.0, opts, alpha, &mut items);
// Link areas are axis-aligned page rectangles: turned text gets none.
for d in &mut items {
if let Draw::Glyphs { link, .. } = d {
*link = None;
}
}
if *turn == TextDirection::Down {
items = upright_east_asian(items);
}
out.push(Draw::Turned { x: *x, y: *y, turn: *turn, items });
}
Placed::Lines { story, path, para, l0, l1, x, y, .. } => lines(doc, *story, path, para, *l0, *l1, *x, *y, opts, alpha, out),
}
}
fn lines(
doc: &Document,
story: StoryRef,
path: &Path,
pl: &ParaLayout,
l0: usize,
l1: usize,
x: f32,
y: f32,
opts: &DisplayOptions,
alpha: f32,
out: &mut Vec<Draw>,
) {
let Some(first) = pl.lines.get(l0) else { return };
let para = doc.para(story, path);
for li in l0..l1 {
let Some(line) = pl.lines.get(li) else { continue };
let top = y + (line.top - first.top);
let base = top + (line.baseline - line.top);
let bottom = top + line.height;
// Commented text gets a soft shade (comment anchors are object markers).
if opts.markup
&& opts.revisions.comments
&& let Some(p) = para
{
let mut open: Option<usize> = None;
let mut ranges = Vec::new();
for off in p.object_offsets() {
match p.object_at(off) {
Some(InlineObject::CommentStart { .. }) => open = open.or(Some(off)),
Some(InlineObject::CommentEnd { .. }) => {
ranges.push((open.take().unwrap_or(0), off));
}
_ => {}
}
}
if let Some(o) = open {
ranges.push((o, p.len()));
}
for (a, b) in ranges {
if b <= line.start || a >= line.stop {
continue;
}
for (x0, x1) in pl.x_spans(li, a.max(line.start), b.min(line.stop)) {
if x1 > x0 {
out.push(Draw::Fill {
rect: Rect::new(x + x0, top, x1 - x0, line.height),
color: Rgb(0xEF, 0xE3, 0xF7),
alpha,
text_bg: false,
});
}
}
}
}
// Backgrounds first: highlight and character shading; character borders group equal adjacent borders.
let mut open: Option<(Border, f32, f32)> = None;
for k in line.c0..line.c1 {
let (Some(c), Some(cx), Some(nx)) = (pl.clusters.get(k), line.cl_left(k), line.cl_right(k)) else { continue };
let Some(st) = pl.styles.get(c.style as usize) else { continue };
if c.kind == ClKind::Marker {
continue;
}
if let Some(h) = st.rc.highlight.or(st.rc.shading) {
out.push(Draw::Fill { rect: Rect::new(x + cx, top, (nx - cx).max(0.0), line.height), color: h, alpha, text_bg: true });
}
let b = st.rc.border;
if let Some((ob, x0, x1)) = open.as_mut()
&& Some(*ob) == b
{
// Right-to-left text grows leftwards.
*x0 = x0.min(x + cx);
*x1 = x1.max(x + nx);
continue;
}
if let Some((ob, x0, x1)) = open.take() {
char_box(&ob, x0, x1, top, bottom, alpha, out);
}
open = b.map(|b| (b, x + cx, x + nx));
}
if let Some((ob, x0, x1)) = open {
char_box(&ob, x0, x1, top, bottom, alpha, out);
}
// Label.
if li == 0
&& let Some(lab) = &pl.label
{
let gx = x + lab.x;
// One draw per font run: a glyph id only means something in its own face.
for run in &lab.runs {
let Some(st) = pl.styles.get(run.style as usize) else { continue };
out.push(Draw::Glyphs {
face: st.face,
size: st.size,
glyphs: lab.glyphs.get(run.glyphs.clone()).into_iter().flatten().map(|g| (g.gid, gx + g.dx, base - st.shift - g.dy)).collect(),
color: text_color(&st.rc.color, None),
alpha,
synth_bold: st.synth_bold,
synth_italic: st.synth_italic,
text: run.text.clone(),
link: None,
ranges: Vec::new(),
text_bg: None,
});
}
}
// Hyphen at a hyphenated line end.
if let Some((si, gid, adv)) = line.hyphen
&& let Some(st) = pl.styles.get(si as usize)
{
let hx = x + line.hyphen_x(adv);
out.push(Draw::Glyphs {
face: st.face,
size: st.size,
glyphs: vec![(gid, hx, base - st.shift)],
color: text_color(&st.rc.color, None),
alpha,
synth_bold: st.synth_bold,
synth_italic: st.synth_italic,
text: "-".into(),
link: None,
ranges: Vec::new(),
text_bg: None,
});
}
// Glyph runs grouped by style.
let mut k = line.c0;
while k < line.c1 {
let Some(c) = pl.clusters.get(k) else { break };
let si = c.style;
let Some(st) = pl.styles.get(si as usize) else {
k += 1;
continue;
};
let mut glyphs = Vec::new();
let mut text = String::new();
let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
let run_start = k;
while k < line.c1 {
let Some(c) = pl.clusters.get(k) else { break };
if c.style != si {
break;
}
let cx = x + line.cl_left(k).unwrap_or(0.0);
if matches!(c.kind, ClKind::Text | ClKind::Space) {
let a = text.len();
// An object's cluster (a field, a note number) stands for the text it shows.
let mut shown = None;
if let Ok(i) = pl.shown.binary_search_by_key(&k, |(i, _, _)| *i)
&& let Some((_, s, r)) = pl.shown.get(i)
{
// All Caps field results read as the capitals drawn (#1473). `r` holds
// byte ranges into `s`, so a character whose capital has another UTF-8
// length keeps its own form and every range still lands where it did.
text.extend(s.chars().map(|ch| {
let up = crate::para::caps_char(&st.rc, ch);
if up.len_utf8() == ch.len_utf8() { up } else { ch }
}));
shown = Some(r);
} 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) {
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
// as the shaper maps them, so a glyph never stands for two cases.
text.extend(t.chars().map(|ch| crate::para::caps_char(&st.rc, ch)));
} else {
text.push_str(t);
}
}
let b = text.len();
let cg = pl.glyphs.get(c.g0 as usize..c.g1 as usize).unwrap_or(&[]);
if let Some(shown) = shown
&& cg.len() > 1
{
// A whole field result is one cluster of many glyphs: each glyph gets the
// text it was shaped from (a ligature its letters), not the whole text as
// one ligature. Without that, a character each (the last takes any remainder).
let bounds: Vec<usize> = text.get(a..b).unwrap_or("").char_indices().map(|(i, _)| a + i).collect();
let known = shown.len() == cg.len();
for (n, g) in cg.iter().enumerate() {
let r = match shown.get(n).filter(|_| known) {
Some(r) => (a + r.start).min(b)..(a + r.end).min(b),
None => {
let from = bounds.get(n).or(bounds.last()).copied().unwrap_or(a);
let to = if n + 1 == cg.len() { b } else { bounds.get(n + 1).copied().unwrap_or(b) };
from..to
}
};
glyphs.push((g.gid, cx + g.dx, base - st.shift - g.dy));
ranges.push(r.start..r.end.max(r.start));
}
k += 1;
continue;
}
if cg.is_empty() {
// No glyph of its own (the second letter of a ligature such as لا): its
// text belongs to the glyphs before it.
let from = ranges.last().map(|r| r.start);
for r in ranges.iter_mut().rev().take_while(|r| Some(r.start) == from) {
r.end = b;
}
}
for g in cg {
glyphs.push((g.gid, cx + g.dx, base - st.shift - g.dy));
ranges.push(a..b);
}
}
k += 1;
}
let run_end = k;
let rc = &st.rc;
let m = &opts.revisions;
// Without markup a deletion is never drawn as ordinary text, even in a layout that
// kept it; nor when deletions are hidden.
if rc.del.is_some() && (!opts.markup || m.hides_deletions()) {
continue;
}
let shown = opts.markup && m.insertions_deletions;
let del = rc.del.filter(|_| shown);
let ins = rc.ins.filter(|_| shown && del.is_none());
let author = |r: u32| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0));
// Moved text: green, double strikethrough where it was, double underline where it went.
let moved_to = doc.move_name(ins).is_some();
let moved_from = del.is_some() && doc.move_name(del).is_some();
let color = match (ins, del) {
(_, Some(_)) if moved_from => MOVE_COLOR,
(Some(_), _) if moved_to => MOVE_COLOR,
(Some(r), _) => m.insert_color.unwrap_or_else(|| author(r)),
(None, Some(r)) => m.delete_color.unwrap_or_else(|| author(r)),
_ => text_color(&rc.color, rc.shading.or(rc.highlight)),
};
// The highlight or shading drawn under this run (as filled in the backgrounds pass).
let text_bg = rc.highlight.or(rc.shading);
let ins_mark = ins.map(|_| if moved_to { InsertMark::DoubleUnderline } else { m.insert_mark });
let del_mark = del.map(|_| if moved_from { DeleteMark::DoubleStrikethrough } else { m.delete_mark });
if del_mark == Some(DeleteMark::Caret) {
// A caret where the text was, instead of the text.
if let Some(cx) = line.cl_left(run_start).map(|c| x + c) {
let h = st.size * 0.3;
let w = (st.size / 18.0).max(0.6);
out.push(Draw::Line {
x0: cx - h * 0.6,
y0: base + 1.0,
x1: cx,
y1: base + 1.0 - h,
width: w,
color,
stroke: Stroke::Solid,
alpha,
text_bg,
});
out.push(Draw::Line {
x0: cx,
y0: base + 1.0 - h,
x1: cx + h * 0.6,
y1: base + 1.0,
width: w,
color,
stroke: Stroke::Solid,
alpha,
text_bg,
});
}
continue;
}
if del_mark == Some(DeleteMark::Hash) {
// Each deleted character shows as #.
let gid = st.face.glyph_for('#');
for g in &mut glyphs {
g.0 = gid;
}
}
// WordArt effects draw the letters from their outlines; the glyphs stay, invisible,
// for the PDF's text.
let art = rc.text_effects.as_ref().filter(|_| ins.is_none() && del.is_none() && !glyphs.is_empty());
if let Some(fx) = art {
let path = crate::wordart::glyph_path(&st.face, st.size, &glyphs, st.synth_italic);
out.push(Draw::Art { path, fx: fx.clone(), color, alpha });
}
if !glyphs.is_empty() {
out.push(Draw::Glyphs {
face: st.face,
size: st.size,
glyphs,
color,
alpha: if art.is_some() { 0.0 } else { alpha },
synth_bold: st.synth_bold || ins_mark == Some(InsertMark::Bold),
synth_italic: st.synth_italic || ins_mark == Some(InsertMark::Italic),
text,
link: rc.link.clone(),
ranges,
text_bg,
});
}
// Decorations across the run (underline skips trailing spaces of the line). Right-to-left
// text inside a run can split it into several visual spans.
let mut end_k = run_end;
while end_k > run_start
&& pl.clusters.get(end_k - 1).is_some_and(|c| matches!(c.kind, ClKind::Space | ClKind::Marker | ClKind::LineBreak))
&& end_k == line.c1
{
end_k -= 1;
}
let thick = (st.size / 18.0).max(0.5);
let underline = match (ins_mark, del_mark) {
(Some(InsertMark::Underline), _) | (_, Some(DeleteMark::Underline)) => Underline::Single,
(Some(InsertMark::DoubleUnderline), _) => Underline::Double,
_ => rc.underline,
};
let double = rc.double_strike || del_mark == Some(DeleteMark::DoubleStrikethrough);
let struck = rc.strike || double || ins_mark == Some(InsertMark::Strikethrough) || del_mark == Some(DeleteMark::Strikethrough);
let spans = if underline != Underline::None || struck { line.spans(run_start, end_k) } else { Vec::new() };
for (x0, x1) in spans.into_iter().map(|(a, b)| (x + a, x + b)).filter(|(a, b)| b > a) {
if underline != Underline::None {
let uy = base - st.shift + st.size * 0.12;
let ucolor = rc.underline_color.unwrap_or(color);
if underline == Underline::Words {
for kk in run_start..end_k {
let Some(c) = pl.clusters.get(kk) else { continue };
if c.kind != ClKind::Text {
continue;
}
let a = x + line.cl_left(kk).unwrap_or(0.0);
let b = x + line.cl_right(kk).unwrap_or(a - x);
if a < x0 - 0.01 || b > x1 + 0.01 {
continue; // drawn with its own span
}
out.push(Draw::Line { x0: a, y0: uy, x1: b, y1: uy, width: thick, color: ucolor, stroke: Stroke::Solid, alpha, text_bg });
}
} else {
let w = if underline == Underline::Thick { thick * 2.0 } else { thick };
out.push(Draw::Line { x0, y0: uy, x1, y1: uy, width: w, color: ucolor, stroke: stroke_of(underline), alpha, text_bg });
}
}
if struck {
let sy = base - st.shift - st.size * 0.28;
let stroke = if double { Stroke::Double } else { Stroke::Solid };
out.push(Draw::Line { x0, y0: sy, x1, y1: sy, width: thick, color, stroke, alpha, text_bg });
}
}
}
// Proofing squiggles.
for (a, b, grammar) in &pl.issues {
if *b <= line.start || *a >= line.stop {
continue;
}
for (x0, x1) in pl.x_spans(li, (*a).max(line.start), (*b).min(line.stop)) {
if x1 - x0 < 1.0 {
continue;
}
let y = base + 2.5;
let color = if *grammar { Rgb(0x2B, 0x57, 0xC0) } else { Rgb(0xE0, 0x24, 0x24) };
out.push(Draw::Line {
x0: x + x0,
y0: y,
x1: x + x1,
y1: y,
width: 0.8,
color,
stroke: if *grammar { Stroke::Double } else { Stroke::Wave },
alpha,
text_bg: None,
});
}
}
// Tab leaders.
for (k, leader) in &line.leaders {
let (Some(c), Some(a), Some(b)) = (pl.clusters.get(*k), line.cl_left(*k), line.cl_right(*k)) else { continue };
let Some(st) = pl.styles.get(c.style as usize) else { continue };
let Some(ch) = leader.char() else { continue };
let gid = st.face.glyph_for(ch);
let adv = (st.face.advance(gid) as f32 * st.size / st.face.upem.max(1.0) as f32).max(1.0);
let (mut gx, end) = (x + a + adv * 0.5, x + b - adv * 1.2);
let mut glyphs = Vec::new();
// Align leader dots to a grid so consecutive lines line up.
gx = (gx / adv).ceil() * adv;
while gx < end && glyphs.len() < 2000 {
glyphs.push((gid, gx, base));
gx += adv;
}
out.push(Draw::Glyphs {
face: st.face,
size: st.size,
glyphs,
color: text_color(&st.rc.color, None),
alpha,
synth_bold: false,
synth_italic: false,
text: String::new(),
link: None,
ranges: Vec::new(),
text_bg: None,
});
}
// Inline objects.
for k in line.c0..line.c1 {
let Some(c) = pl.clusters.get(k) else { continue };
let ClKind::Object(oi) = c.kind else { continue };
if c.obj_h <= 0.0 {
continue;
}
let cx = x + line.cl_left(k).unwrap_or(0.0);
let obj = para.and_then(|p| p.objects.get(oi));
let rect = inline_rect(obj, cx, base, c.adv, c.obj_h);
let spin = obj.and_then(InlineObject::frame).map(|(_, _, f)| f.spin()).unwrap_or_default();
match obj {
Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })) => {
object_draws(o, rect, Spin::default(), alpha, opts, out)
}
Some(InlineObject::Graphic { graphic, alt, .. }) => {
let mut draws = Vec::new();
spun(spin, rect, graphic_draws(doc, graphic, rect, alpha), &mut draws);
out.push(Draw::Figure { alt: alt.clone(), kind: graphic.kind, draws })
}
Some(InlineObject::Equation { .. }) => {
if let Some((_, ml)) = pl.maths.iter().find(|(k, _)| *k == oi) {
equation(&ml.items, cx, base, alpha, opts.placeholders, out);
}
}
_ => {}
}
}
// Enclosed characters' shapes, in the text's colour.
for (k, e) in &pl.enclosures {
if *k < line.c0 || *k >= line.c1 {
continue;
}
let (Some(c), Some(left)) = (pl.clusters.get(*k), line.cl_left(*k)) else { continue };
let Some(st) = pl.styles.get(c.style as usize) else { continue };
let rect = Rect::new(x + left + e.x, base - st.shift - e.top, e.w, e.h);
out.push(Draw::Shape {
rect,
kind: e.kind,
fill: None,
stroke: Some(text_color(&st.rc.color, None)),
stroke_width: e.stroke,
effects: Default::default(),
corner: None,
});
}
// Ruby (Phonetic Guide) text over its base text, in runs of one style.
for r in pl.rubies.iter().filter(|r| (line.c0..line.c1).contains(&r.cluster)) {
let Some(bst) = pl.clusters.get(r.cluster).and_then(|c| pl.styles.get(c.style as usize)) else { continue };
if bst.rc.del.is_some() && !opts.markup {
continue;
}
let cx = x + line.cl_left(r.cluster).unwrap_or(0.0);
let y = base - bst.shift - r.raise;
let mut i = 0;
while let Some(&(style, _)) = r.glyphs.get(i) {
let n = r.glyphs.get(i..).map_or(0, |g| g.iter().take_while(|(s, _)| *s == style).count()).max(1);
let run = r.glyphs.get(i..i + n).unwrap_or(&[]);
let ranges = r.ranges.get(i..i + n).map(<[_]>::to_vec).unwrap_or_default();
i += n;
let Some(st) = pl.styles.get(style as usize) else { continue };
out.push(Draw::Glyphs {
face: st.face,
size: st.size,
glyphs: run.iter().map(|(_, g)| (g.gid, cx + g.dx, y - g.dy)).collect(),
color: text_color(&st.rc.color, st.rc.shading.or(st.rc.highlight)),
alpha,
synth_bold: st.synth_bold,
synth_italic: st.synth_italic,
text: r.text.clone(),
link: None,
ranges,
text_bg: st.rc.highlight.or(st.rc.shading),
});
}
}
// Formatting marks.
let marks = opts.always.shown(opts.marks);
if marks.any() {
let msize = pl.styles.first().map(|s| s.size).unwrap_or(11.0);
for k in line.c0..line.c1 {
let Some(c) = pl.clusters.get(k) else { continue };
let cx = x + line.cl_left(k).unwrap_or(0.0);
let nx = x + line.cl_right(k).unwrap_or(cx - x);
let size = pl.styles.get(c.style as usize).map(|s| s.size).unwrap_or(msize);
let arrow = if line.rtl { '←' } else { '→' };
match c.kind {
ClKind::Space if marks.spaces => {
out.push(Draw::Mark { x: (cx + nx) / 2.0 - size * 0.12, baseline: base - size * 0.08, size, ch: '·', color: None })
}
ClKind::Tab if marks.tabs => {
out.push(Draw::Mark { x: cx + ((nx - cx) / 2.0 - size * 0.3).max(0.0), baseline: base, size, ch: arrow, color: None })
}
ClKind::LineBreak if marks.paragraphs => out.push(Draw::Mark { x: cx + 1.0, baseline: base, size, ch: '↵', color: None }),
ClKind::PageBreak | ClKind::ColumnBreak if marks.paragraphs => {
let label = if c.kind == ClKind::PageBreak { '⤓' } else { '⇥' };
out.push(Draw::Line {
x0: cx + 2.0,
y0: base - size * 0.3,
x1: x + line.right,
y1: base - size * 0.3,
width: 0.5,
color: Rgb(0x60, 0x60, 0x60),
stroke: Stroke::Dotted,
alpha,
text_bg: None,
});
out.push(Draw::Mark { x: cx + 1.0, baseline: base, size, ch: label, color: None });
}
_ => {}
}
}
if line.end == LineEnd::Para && marks.paragraphs {
// The mark follows the line's visual end, past every glyph, even where the
// logically last text runs against the paragraph's direction.
let ex = x + line.visual_end_x();
let size = pl.lines.first().map(|_| msize).unwrap_or(msize);
// A right-to-left paragraph's mark sits at its end, on the left.
let mx = if line.rtl { ex - 1.0 - size * 0.6 } else { ex + 1.0 };
// A tracked (inserted or deleted) paragraph mark is drawn in its author's colour.
let m = &opts.revisions;
let color = para.filter(|_| opts.markup && m.insertions_deletions).and_then(|p| match (p.mark.ins, p.mark.del) {
(ins, del) if doc.move_name(del).is_some() || (del.is_none() && doc.move_name(ins).is_some()) => Some(MOVE_COLOR),
(_, Some(r)) => Some(
m.delete_color
.unwrap_or_else(|| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0))),
),
(Some(r), None) => Some(
m.insert_color
.unwrap_or_else(|| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0))),
),
_ => None,
});
// The last paragraph of a section ends in a section break instead of a plain ¶.
if let Some(start) = section_break_after(doc, story, path) {
let (x0, x1) = if line.rtl { (x + line.left, ex - 2.0) } else { (ex + 2.0, x + line.right) };
section_break_mark(start, x0, x1, base, size, alpha, out);
} else {
out.push(Draw::Mark { x: mx, baseline: base, size, ch: '', color });
}
}
}
let _ = bottom;
}
}
/// The kind of section break that ends at body paragraph `path`: the start type of the section
/// that follows it. `None` for any other paragraph, and for the document's last section.
fn section_break_after(doc: &Document, story: StoryRef, path: &Path) -> Option<SectionStart> {
if story != StoryRef::Body || path.0.len() != 1 {
return None;
}
let i = *path.0.first()? as usize;
match doc.body.get(i).map(|b| &**b) {
Some(Block::Para(p)) if p.section.is_some() => {}
_ => return None,
}
let secs = doc.sections();
let k = secs.iter().position(|(end, _)| *end == i)?;
secs.get(k + 1).map(|(_, s)| s.start)
}
/// On-screen name of a section break.
pub fn section_break_label(start: SectionStart) -> &'static str {
match start {
SectionStart::NextPage => "Section Break (Next Page)",
SectionStart::Continuous => "Section Break (Continuous)",
SectionStart::EvenPage => "Section Break (Even Page)",
SectionStart::OddPage => "Section Break (Odd Page)",
SectionStart::NextColumn => "Section Break (Next Column)",
}
}
/// Width of a [`Draw::MarkText`] label.
pub fn mark_text_width(text: &str, size: f32) -> f32 {
let face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
let k = size / face.upem.max(1.0) as f32;
text.chars().map(|c| face.advance(face.glyph_for(c)) as f32 * k).sum()
}
/// A section break mark between `x0` and `x1`: a dotted double rule with the break's name centred
/// in it (just the name when there is no room for the rule).
fn section_break_mark(start: SectionStart, x0: f32, x1: f32, base: f32, size: f32, alpha: f32, out: &mut Vec<Draw>) {
let text = section_break_label(start);
let lsize = (size * 0.8).clamp(6.0, 11.0);
let w = mark_text_width(text, lsize);
let gap = lsize * 0.4;
let mid = base - size * 0.3;
let color = Rgb(0x60, 0x60, 0x60);
if x1 - x0 <= w + 4.0 * gap {
out.push(Draw::MarkText { x: x0, baseline: base, size: lsize, text: text.into() });
return;
}
let tx = (x0 + x1 - w) / 2.0;
for (a, b) in [(x0, tx - gap), (tx + w + gap, x1)] {
for y in [mid - 1.2, mid + 1.2] {
out.push(Draw::Line { x0: a, y0: y, x1: b, y1: y, width: 0.5, color, stroke: Stroke::Dotted, alpha, text_bg: None });
}
}
out.push(Draw::MarkText { x: tx, baseline: mid + lsize * 0.33, size: lsize, text: text.into() });
}
/// An equation's glyphs and rules with its origin at (`x`, `base`).
fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: &mut Vec<Draw>) {
for it in items {
match it {
MItem::Glyphs { face, size, color, synth_bold, synth_italic, glyphs, text } => out.push(Draw::Glyphs {
face: *face,
size: *size,
glyphs: glyphs.iter().map(|(g, gx, gy)| (*g, x + gx, base - gy)).collect(),
color: *color,
alpha,
synth_bold: *synth_bold,
synth_italic: *synth_italic,
text: text.clone(),
link: None,
// Equation text maps back through the font (no per-glyph ranges from the math layout).
ranges: Vec::new(),
text_bg: None,
}),
MItem::Rect { x: rx, y, w, h, color } => {
out.push(Draw::Fill { rect: Rect::new(x + rx, base - y - h, *w, *h), color: *color, alpha, text_bg: false })
}
MItem::Line { x0, y0, x1, y1, width, color, dotted } => out.push(Draw::Line {
x0: x + x0,
y0: base - y0,
x1: x + x1,
y1: base - y1,
width: *width,
color: *color,
stroke: if *dotted { Stroke::Dotted } else { Stroke::Solid },
alpha,
text_bg: None,
}),
MItem::ScreenOnly(inner) => {
if screen {
equation(std::slice::from_ref(inner), x, base, alpha, screen, out);
}
}
}
}
}
/// The draws of a chart or diagram's items inside `rect` (page coordinates). The items were built
/// for the size `g.w` × `g.h` (0: `rect`'s own size), so they are scaled to `rect`; strokes and text
/// by the mean of the two scales.
fn graphic_draws(doc: &Document, g: &Graphic, rect: Rect, alpha: f32) -> Vec<Draw> {
let (sx, sy) = (scale_of(rect.w, g.w), scale_of(rect.h, g.h));
let k = (sx + sy) / 2.0;
let inside = |r: &[f32; 4]| Rect::new(rect.x + r[0] * sx, rect.y + r[1] * sy, r[2] * sx, r[3] * sy);
let mut out = Vec::with_capacity(g.items.len());
for it in &g.items {
match it {
GraphicItem::Shape { rect: r, kind, fill, stroke, stroke_width } => out.push(Draw::Shape {
rect: inside(r),
kind: *kind,
fill: *fill,
stroke: *stroke,
stroke_width: stroke_width * k,
effects: Default::default(),
corner: None,
}),
GraphicItem::Path { segs, fill, stroke, stroke_width } => {
let segs = page_segs(segs, |x, y| (rect.x + x * sx, rect.y + y * sy));
out.push(Draw::Path { segs, fill: *fill, stroke: *stroke, stroke_width: stroke_width * k })
}
GraphicItem::Text { rect: r, .. } => out.extend(text_glyphs(doc, inside(r), it, alpha, k)),
GraphicItem::Image { rect: r, media } => {
out.push(Draw::Image { rect: inside(r), media: media.clone(), crop: [0.0; 4], alpha, alt: String::new() })
}
}
}
out
}
/// Scale from the size `built` points a graphic's items were made for to `len` points; 1 when
/// the built size is unknown (0).
fn scale_of(len: f32, built: f32) -> f32 {
if built.is_finite() && built > 0.0 && len.is_finite() { (len / built).max(0.0) } else { 1.0 }
}
/// Path segments mapped to page coordinates by `map`, cleaned for the rasterizer and PDF: a segment
/// with a non-finite number is dropped, and so is anything before a move (or after a broken one).
fn page_segs(segs: &[PathSeg], map: impl Fn(f32, f32) -> (f32, f32)) -> Vec<PathSeg> {
let fin = |p: (f32, f32)| p.0.is_finite() && p.1.is_finite();
let mut out = Vec::with_capacity(segs.len());
let mut open = false;
for s in segs {
match *s {
PathSeg::Move(x, y) => {
let p = map(x, y);
open = fin(p);
if open {
out.push(PathSeg::Move(p.0, p.1));
}
}
PathSeg::Line(x, y) => {
let p = map(x, y);
if open && fin(p) {
out.push(PathSeg::Line(p.0, p.1));
}
}
PathSeg::Cubic(a, b, c, d, e, f) => {
let (p1, p2, p3) = (map(a, b), map(c, d), map(e, f));
if open && fin(p1) && fin(p2) && fin(p3) {
out.push(PathSeg::Cubic(p1.0, p1.1, p2.0, p2.1, p3.0, p3.1));
}
}
PathSeg::Close => {
if open {
out.push(PathSeg::Close);
}
open = false;
}
}
}
out
}
/// A path of clean segments (see [`page_segs`]) as a kurbo path, for the rasteriser and PDF.
/// The draws of a shape at `rect`: a preset outline, or a freeform's paths (an ink stroke left out
/// under Review › Hide Ink).
#[allow(clippy::too_many_arguments)]
fn shape_draws(
rect: Rect,
kind: ShapeKind,
fill: Option<Rgb>,
stroke: Option<Rgb>,
stroke_width: f32,
effects: wordcraft_doc::effects::ShapeEffects,
freeform: Option<&wordcraft_doc::freeform::Freeform>,
extra: &wordcraft_doc::connector::ShapeExtra,
opts: &DisplayOptions,
out: &mut Vec<Draw>,
) {
if kind.is_open() {
line_draws(rect, kind, stroke, stroke_width, effects, extra, out);
return;
}
let Some(f) = freeform.filter(|_| kind == ShapeKind::Freeform) else {
out.push(Draw::Shape { rect, kind, fill, stroke, stroke_width, effects, corner: extra.corner });
return;
};
if f.is_ink() && opts.hide_ink {
return;
}
let width = if stroke_width.is_finite() { stroke_width.clamp(0.0, 200.0) } else { 0.75 };
for (pts, closed) in f.placed(rect.x, rect.y, rect.w, rect.h) {
if f.is_ink() || (!closed && fill.is_none()) {
if let Some(color) = stroke {
let mut pts: Vec<(f32, f32)> = pts.iter().map(|[x, y]| (*x, *y)).collect();
if closed && let Some(first) = pts.first().copied() {
pts.push(first);
}
out.push(Draw::Ink { pts, color, width: width.max(0.25), alpha: f.alpha });
}
continue;
}
let mut segs: Vec<PathSeg> = Vec::with_capacity(pts.len() + 1);
for (i, [x, y]) in pts.iter().enumerate() {
segs.push(if i == 0 { PathSeg::Move(*x, *y) } else { PathSeg::Line(*x, *y) });
}
if closed {
segs.push(PathSeg::Close);
}
out.push(Draw::Path { segs, fill: if closed { fill } else { None }, stroke, stroke_width: width });
}
}
/// A line or connector at `rect` (its unflipped frame: flips are the frame's turn), with its
/// arrowheads.
fn line_draws(
rect: Rect,
kind: ShapeKind,
stroke: Option<Rgb>,
stroke_width: f32,
effects: wordcraft_doc::effects::ShapeEffects,
extra: &wordcraft_doc::connector::ShapeExtra,
out: &mut Vec<Draw>,
) {
use wordcraft_doc::connector::{connector_segs, end_direction};
match connector_segs(kind, rect.w, rect.h) {
Some(segs) => {
let segs = segs
.into_iter()
.map(|s| match s {
PathSeg::Move(x, y) => PathSeg::Move(rect.x + x, rect.y + y),
PathSeg::Line(x, y) => PathSeg::Line(rect.x + x, rect.y + y),
PathSeg::Cubic(a, b, c, d, e, f) => PathSeg::Cubic(rect.x + a, rect.y + b, rect.x + c, rect.y + d, rect.x + e, rect.y + f),
PathSeg::Close => PathSeg::Close,
})
.collect();
out.push(Draw::Path { segs, fill: None, stroke, stroke_width });
}
None => out.push(Draw::Shape { rect, kind, fill: None, stroke, stroke_width, effects, corner: None }),
}
let Some(color) = stroke else { return };
// Our line runs from the bottom-left corner to the top-right; connectors from the top-left
// to the bottom-right.
let (start, end) =
if kind == ShapeKind::Line { ((rect.x, rect.bottom()), (rect.right(), rect.y)) } else { ((rect.x, rect.y), (rect.right(), rect.bottom())) };
let width = if stroke_width.is_finite() { stroke_width.clamp(0.25, 200.0) } else { 0.75 };
let len = (width * 3.5).max(7.0);
for (on, tip, at_end) in [(extra.arrow_start, start, false), (extra.arrow_end, end, true)] {
if !on {
continue;
}
let (dx, dy) = end_direction(kind, rect.w, rect.h, at_end);
let (bx, by) = (tip.0 - dx * len, tip.1 - dy * len);
let (nx, ny) = (-dy * len * 0.45, dx * len * 0.45);
let segs = vec![PathSeg::Move(tip.0, tip.1), PathSeg::Line(bx + nx, by + ny), PathSeg::Line(bx - nx, by - ny), PathSeg::Close];
out.push(Draw::Path { segs, fill: Some(color), stroke: None, stroke_width: 0.0 });
}
}
pub fn seg_path(segs: &[PathSeg]) -> BezPath {
let mut p = BezPath::new();
for s in segs {
match *s {
PathSeg::Move(x, y) => p.move_to((x as f64, y as f64)),
PathSeg::Line(x, y) => p.line_to((x as f64, y as f64)),
PathSeg::Cubic(a, b, c, d, e, f) => p.curve_to((a as f64, b as f64), (c as f64, d as f64), (e as f64, f as f64)),
PathSeg::Close => p.close_path(),
}
}
p
}
/// One line of a `GraphicItem::Text` in `r`, shaped in its font (the document's body font when
/// unset) and aligned horizontally, centred vertically, at its size times `k`. `None` when there is
/// nothing to draw, or the item's rectangle or size isn't a finite number (a degenerate scale:
/// `r` itself is always finite, as [`Rect::new`] makes it).
fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) -> Option<Draw> {
let GraphicItem::Text { rect, text, size, color, bold, align, font } = it else { return None };
let size = *size * k;
if !rect.iter().chain([&size]).all(|v| v.is_finite()) {
return None;
}
let family = font.as_deref().filter(|f| !f.is_empty()).unwrap_or(&doc.settings.minor_font);
let size = size.clamp(1.0, 400.0);
let resolved = wordcraft_fonts::word::resolve(family, *bold, false);
let face = resolved.face.get();
let shaped = wordcraft_fonts::shape(face, text, &[], |c| c);
if shaped.is_empty() {
return None;
}
let k = size / face.upem.max(1.0) as f32;
let width: f32 = shaped.iter().map(|g| g.x_advance as f32 * k).sum();
let (ascent, descent) = wordcraft_fonts::word::line_metrics(face);
let baseline = r.y + r.h / 2.0 + (ascent - descent) as f32 * k / 2.0;
let mut pen = match align {
TextAlign::Left => r.x,
TextAlign::Center => r.x + (r.w - width) / 2.0,
TextAlign::Right => r.x + r.w - width,
};
let mut glyphs = Vec::with_capacity(shaped.len());
for g in &shaped {
glyphs.push((g.gid, pen + g.x_offset as f32 * k, baseline - g.y_offset as f32 * k));
pen += g.x_advance as f32 * k;
}
Some(Draw::Glyphs {
face: resolved.face,
size,
glyphs,
color: *color,
alpha,
synth_bold: resolved.synth_bold,
synth_italic: false,
text: text.clone(),
link: None,
ranges: Vec::new(),
text_bg: None,
})
}
fn author_index(doc: &Document, author: &str) -> u32 {
let mut seen: Vec<&str> = Vec::new();
for r in &doc.revisions {
if !seen.contains(&r.author.as_str()) {
seen.push(&r.author);
}
}
seen.iter().position(|a| *a == author).unwrap_or(0) as u32
}
/// Automatic colour is black, or white on a dark background.
pub fn text_color(c: &TextColor, background: Option<Rgb>) -> Rgb {
match c {
TextColor::Rgb(r) => *r,
TextColor::Auto => {
if background.is_some_and(|b| b.luma() < 100.0) {
Rgb::WHITE
} else {
Rgb::BLACK
}
}
}
}
/// `items` turned by `spin` about the centre of `rect` (as they are, unturned).
fn spun(spin: Spin, rect: Rect, items: Vec<Draw>, out: &mut Vec<Draw>) {
if spin.is_identity() {
out.extend(items);
} else {
out.push(Draw::Rotated { cx: rect.x + rect.w / 2.0, cy: rect.y + rect.h / 2.0, spin, items });
}
}
/// A picture, shape or group drawn in `rect` (its unturned frame), turned by its own spin about
/// the rect's centre; a group's members are turned inside it. `outer` is the spin it is already
/// drawn inside (its group's). Nothing for anything else.
fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32, opts: &DisplayOptions, out: &mut Vec<Draw>) {
let own = o.frame().map(|(_, _, f)| f.spin()).unwrap_or_default();
match o {
InlineObject::Image { media, crop, alt, .. } => {
spun(own, rect, vec![Draw::Image { rect, media: media.clone(), crop: *crop, alpha, alt: alt.clone() }], out)
}
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, freeform, extra, .. } => {
let effects = effects_in(*effects, own.within(outer));
let mut items = Vec::new();
shape_draws(rect, *kind, *fill, *stroke, *stroke_width, effects, freeform.as_deref(), extra, opts, &mut items);
spun(own, rect, items, out)
}
InlineObject::Group { .. } => {
let mut members = Vec::new();
for ([x, y, w, h], c) in o.group_rects(rect.x, rect.y, rect.w, rect.h) {
if c.frame().is_some_and(|(_, _, f)| f.hidden) {
continue;
}
object_draws(c, Rect::new(x, y, w, h), own.within(outer), alpha, opts, &mut members);
}
match o.canvas_style() {
// A Drawing Canvas: its background and outline, then its members clipped to its
// frame, all inside its turn.
Some(c) => spun(own, rect, vec![canvas_background(rect, c), Draw::Clip { rect, items: members }], out),
None => spun(own, rect, members, out),
}
}
_ => {}
}
}
/// A shape's `effects` as drawn turned by `spin` (all the turns it is drawn inside): glows and
/// soft edges turn with the shape, and so does a shadow's offset when it rotates with the shape
/// (`rotWithShape`); one that doesn't keeps its direction on the page, so its angle is turned back.
pub(crate) fn effects_in(effects: wordcraft_doc::effects::ShapeEffects, spin: Spin) -> wordcraft_doc::effects::ShapeEffects {
let mut e = effects;
if let Some(s) = e.shadow.as_mut().filter(|s| !s.rot_with_shape && !spin.is_identity()) {
let a = s.angle.to_radians();
let (dx, dy) = spin.unapply(0.0, 0.0, a.cos(), a.sin());
s.angle = wordcraft_geom::normalize_degrees(dy.atan2(dx).to_degrees());
}
e
}
/// Where inline object `obj` is drawn, given its cluster's box (`adv` × `obj_h` standing on the
/// baseline at `cx`): inside the room kept for its effects.
/// A rotated one's unrotated frame, centred in that room.
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, obj_h: f32) -> Rect {
let frame = obj.and_then(InlineObject::frame);
let [l, t, r, b] = frame.map_or([0.0; 4], |(_, _, float)| float.effect_extent());
let room = Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h - t - b).max(0.0));
let Some((w, h, float)) = frame.filter(|(_, _, f)| f.rot != 0.0) else { return room };
// The room is the rotated bounds of the frame: undo the rotation.
let (w, h) = (wordcraft_geom::finite(w).clamp(1.0, 4000.0), wordcraft_geom::finite(h).clamp(1.0, 4000.0));
let (bw, _) = float.spin().extent(w, h);
let k = if bw > 0.0 { room.w / bw } else { 1.0 };
let (fw, fh) = (w * k, h * k);
Rect::new(room.x + (room.w - fw) / 2.0, room.y + (room.h - fh) / 2.0, fw, fh)
}
/// A Drawing Canvas's background and outline over its frame `rect`.
fn canvas_background(rect: Rect, c: &wordcraft_doc::para::CanvasStyle) -> Draw {
Draw::Shape {
rect,
kind: ShapeKind::Rectangle,
fill: c.fill,
stroke: c.stroke,
stroke_width: c.stroke_width,
effects: Default::default(),
corner: None,
}
}
/// Whether `c` stands upright in top-to-bottom East Asian text (`tbRl`) rather than turning with
/// the line: ideographs, kana, Hangul, full-width letters and digits, and the ideographic comma
/// and full stop (an approximation of Unicode's vertical orientation, UAX #50). Brackets, dashes
/// and the prolonged sound mark turn with the line, as their vertical forms do.
pub fn upright_in_vertical(c: char) -> bool {
matches!(c,
'\u{1100}'..='\u{11FF}'
| '\u{2E80}'..='\u{2FFF}'
| '\u{3001}'..='\u{3007}'
| '\u{3012}'..='\u{3013}'
| '\u{3020}'..='\u{302F}'
| '\u{3031}'..='\u{303F}'
| '\u{3040}'..='\u{30FB}'
| '\u{30FD}'..='\u{30FF}'
| '\u{3100}'..='\u{31EF}'
| '\u{31F0}'..='\u{4DBF}'
| '\u{4E00}'..='\u{9FFF}'
| '\u{A960}'..='\u{A97F}'
| '\u{AC00}'..='\u{D7FF}'
| '\u{F900}'..='\u{FAFF}'
| '\u{FE30}'..='\u{FE4F}'
| '\u{FF01}'..='\u{FF07}'
| '\u{FF0A}'..='\u{FF0C}'
| '\u{FF0E}'..='\u{FF3A}'
| '\u{FF3C}'
| '\u{FF3E}'..='\u{FF5A}'
| '\u{FFE0}'..='\u{FFE6}'
| '\u{20000}'..='\u{3FFFF}'
)
}
/// Top-to-bottom text drawn turned 90° clockwise, with its East Asian characters turned back
/// upright, each about the centre of its em square (`tbRl`: ideographs stand, Latin lies).
/// Glyph runs are split where uprightness changes, so the text keeps its order.
fn upright_east_asian(items: Vec<Draw>) -> Vec<Draw> {
let mut out = Vec::with_capacity(items.len());
for d in items {
let Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges, text_bg } = d else {
out.push(d);
continue;
};
let up = |k: usize| ranges.get(k).and_then(|r| text.get(r.clone())).and_then(|t| t.chars().next()).is_some_and(upright_in_vertical);
if ranges.len() != glyphs.len() || !(0..glyphs.len()).any(up) {
out.push(Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges, text_bg });
continue;
}
let piece = |k0: usize, k1: usize| -> Draw {
let gs = glyphs.get(k0..k1).unwrap_or(&[]).to_vec();
let rs = ranges.get(k0..k1).unwrap_or(&[]);
let (b0, b1) = (rs.iter().map(|r| r.start).min().unwrap_or(0), rs.iter().map(|r| r.end).max().unwrap_or(0));
let t = text.get(b0..b1).unwrap_or("").to_string();
let rs = rs.iter().map(|r| r.start.saturating_sub(b0)..r.end.saturating_sub(b0)).collect();
Draw::Glyphs { face, size, glyphs: gs, color, alpha, synth_bold, synth_italic, text: t, link: link.clone(), ranges: rs, text_bg }
};
let mut k0 = 0;
while k0 < glyphs.len() {
let u = up(k0);
let k1 = (k0 + 1..glyphs.len()).find(|&k| up(k) != u).unwrap_or(glyphs.len());
if !u {
out.push(piece(k0, k1));
} else {
for k in k0..k1 {
let Some(&(_, gx, base)) = glyphs.get(k) else { continue };
// The em square: one em along the line (the advance to the next glyph when
// known), from about 0.88 em above the baseline to 0.12 em below it.
let adv = glyphs.get(k + 1).map(|g| g.1 - gx).filter(|a| *a > 0.0 && *a < size * 2.0).unwrap_or(size);
let (cx, cy) = (gx + adv / 2.0, base - size * 0.38);
let mut g = piece(k, k + 1);
// The ideographic comma and full stop sit in the upper right of their square in
// vertical text (lower left in horizontal): moved there before standing up.
if let Draw::Glyphs { text, glyphs, .. } = &mut g
&& matches!(text.chars().next(), Some('\u{3001}' | '\u{3002}' | '\u{FF0C}' | '\u{FF0E}'))
{
for (_, x, y) in glyphs.iter_mut() {
*x += size * 0.55;
*y -= size * 0.55;
}
}
out.push(Draw::Rotated { cx, cy, spin: Spin::new(-90.0, false, false), items: vec![g] });
}
}
k0 = k1;
}
}
out
}