mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 11:16:50 -06:00
Text: drop caps
This commit is contained in:
Generated
+1
@@ -910,6 +910,7 @@ dependencies = [
|
||||
"proptest",
|
||||
"regex",
|
||||
"unicode-bidi",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -57,6 +57,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack).
|
||||
- Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete.
|
||||
- Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle.
|
||||
- Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropCapStyle): the first characters (grapheme clusters, up to 150) are enlarged from the first line's cap height down to the baseline of line N (up to 25), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), baselines don't move; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps.
|
||||
- User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates.
|
||||
- Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption.
|
||||
- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset.
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
unicode-bidi = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
designcraft-geom = { workspace = true }
|
||||
designcraft-color = { workspace = true }
|
||||
designcraft-doc = { workspace = true }
|
||||
|
||||
+195
-9
@@ -160,6 +160,20 @@ pub struct Line {
|
||||
pub hj: u8,
|
||||
/// The paragraph's Keep Options couldn't be honoured here (Highlight Keep Violations).
|
||||
pub keep_violation: bool,
|
||||
/// The drop cap a paragraph's first line starts with (its first glyphs, set down to a later
|
||||
/// line's baseline).
|
||||
pub drop_cap: Option<DropCapBox>,
|
||||
}
|
||||
|
||||
/// Where a drop cap sits (frame inner space).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct DropCapBox {
|
||||
/// Story bytes from its line's start up to here are the drop cap's.
|
||||
pub end: usize,
|
||||
/// Its baseline.
|
||||
pub baseline: f64,
|
||||
/// The area it covers: from its line's top down to the text below its baseline.
|
||||
pub rect: Rect,
|
||||
}
|
||||
|
||||
/// A paragraph rule or shading rectangle in frame inner space.
|
||||
@@ -545,6 +559,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
notes.number_refs(doc, prange.clone(), cur_frame.and_then(|f| f.page), &mut sub);
|
||||
}
|
||||
let sub_objects = sub.objects.clone();
|
||||
let drop_end = drop_cap_end(&story.text, prange.clone(), &pp, sub.vertical);
|
||||
let mut table = StyleTable { styles: &mut styles_tab, missing: &mut missing_fonts };
|
||||
let env = shape::TypeEnv { auto_leading: pp.auto_leading, adv: doc.settings.advanced_type };
|
||||
let mut sp = shape::shape_para(
|
||||
@@ -560,7 +575,18 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
&pp.nested_styles,
|
||||
&pp.grep_styles,
|
||||
&[],
|
||||
(drop_end, &pp.drop_cap_style),
|
||||
);
|
||||
// Drop cap: the size and indent come from the text beside it (`base_*` when there's none).
|
||||
let base_leading = match base_chars.leading {
|
||||
designcraft_doc::Leading::Auto => base_chars.size * pp.auto_leading,
|
||||
designcraft_doc::Leading::Points(v) => v,
|
||||
};
|
||||
let base_cap = {
|
||||
let face = db.face(&base_chars.font_family, &base_chars.font_style);
|
||||
face.cap_height * base_chars.size / face.upem * base_chars.v_scale
|
||||
};
|
||||
let drop_grid = cur_frame.and_then(|f| f.grid).filter(|_| pp.grid_align == GridAlign::AllLines).map(|g| g.1);
|
||||
if !pp.nested_line_styles.is_empty() && cur.fi < frames.len() {
|
||||
// Nested line styles: find where the first lines end in this column, restyle them, and
|
||||
// look again (the style changes the widths) until the lines settle.
|
||||
@@ -569,9 +595,17 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
let spacing = spacing_for(&pp, base_chars.size);
|
||||
let mut lines: Vec<(std::ops::Range<usize>, String)> = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let width = |j: usize| (col.width() - pp.left_indent - pp.right_indent - if j == 0 { pp.first_line_indent } else { 0.0 }).max(1.0);
|
||||
// The same spacing, hyphenation and breaker as the layout below.
|
||||
// The same drop cap, spacing, hyphenation and breaker as the layout below.
|
||||
let mut gl = sp.glyphs.clone();
|
||||
let dc = split_drop_cap(&mut gl, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid);
|
||||
let width = |j: usize| {
|
||||
let ind = match &dc {
|
||||
Some(dc) if j < dc.lines => dc.width,
|
||||
_ if j == 0 => pp.first_line_indent,
|
||||
_ => 0.0,
|
||||
};
|
||||
(col.width() - pp.left_indent - pp.right_indent - ind).max(1.0)
|
||||
};
|
||||
apply_desired_spacing(&mut gl, &pp);
|
||||
let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
|
||||
let breaks = if pp.composer == Composer::SingleLine || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 {
|
||||
@@ -613,9 +647,11 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
&pp.nested_styles,
|
||||
&pp.grep_styles,
|
||||
&lines,
|
||||
(drop_end, &pp.drop_cap_style),
|
||||
);
|
||||
}
|
||||
}
|
||||
let drop_cap = split_drop_cap(&mut sp.glyphs, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid);
|
||||
match pp.list_type {
|
||||
designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => {
|
||||
// A named list: carries on past other paragraphs (and from earlier stories).
|
||||
@@ -643,10 +679,6 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
apply_desired_spacing(&mut glyphs, &pp);
|
||||
let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
|
||||
let base_size = base_chars.size;
|
||||
let base_leading = match base_chars.leading {
|
||||
designcraft_doc::Leading::Auto => base_size * pp.auto_leading,
|
||||
designcraft_doc::Leading::Points(v) => v,
|
||||
};
|
||||
let spacing = spacing_for(&pp, base_size);
|
||||
// Paragraph start options.
|
||||
if force_col[pi] {
|
||||
@@ -671,6 +703,10 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
let mut col_first_line = 0usize;
|
||||
let has_tabs = glyphs.iter().any(|g| g.ch == '\t');
|
||||
let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span
|
||||
let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft;
|
||||
// The drop cap's line (frame, index) and the baseline it sits on, once that line is set.
|
||||
let mut drop_line: Option<(usize, usize)> = None;
|
||||
let mut drop_baseline: Option<f64> = None;
|
||||
loop {
|
||||
if cur.fi >= frames.len() {
|
||||
out.overset_at = Some(glyphs.get(g0).map(|g| g.byte).unwrap_or(prange.start));
|
||||
@@ -695,7 +731,15 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
let slots = estimate_slots(f, col, est_first, est_jump, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no);
|
||||
let width = |j: usize| -> f64 {
|
||||
let (x0, x1) = slots.get(j).copied().unwrap_or((col.x0, col.x1));
|
||||
let ind = pp.left_indent + pp.right_indent + if line_no + j == 0 { pp.first_line_indent } else { 0.0 };
|
||||
// Beside the drop cap (in the column the paragraph starts in), its width; else the
|
||||
// first-line indent.
|
||||
let ind = pp.left_indent
|
||||
+ pp.right_indent
|
||||
+ match &drop_cap {
|
||||
Some(dc) if line_no + j < dc.lines && col_first_line == 0 => dc.width,
|
||||
_ if line_no + j == 0 => pp.first_line_indent,
|
||||
_ => 0.0,
|
||||
};
|
||||
(x1 - x0 - ind).max(1.0)
|
||||
};
|
||||
let rest = &glyphs[g0..];
|
||||
@@ -788,12 +832,30 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
moved = true;
|
||||
break;
|
||||
}
|
||||
let ind_l = pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 };
|
||||
// A drop cap sits at the start-side indent; the first-line indent doesn't move it.
|
||||
let beside = drop_cap.as_ref().filter(|dc| line_no < dc.lines && col_first_line == 0).map(|dc| dc.width);
|
||||
let (ind_l, ind_r) = match beside {
|
||||
Some(w) if rtl => (pp.left_indent, pp.right_indent + w),
|
||||
Some(w) => (pp.left_indent + w, pp.right_indent),
|
||||
None => (pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }, pp.right_indent),
|
||||
};
|
||||
let lx0 = x0 + ind_l;
|
||||
let lx1 = x1 - pp.right_indent;
|
||||
let lx1 = x1 - ind_r;
|
||||
let last = k + 1 == breaks.len();
|
||||
let (mut placed, end_x, ratio) =
|
||||
layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, last, b.forced && !last, f.left_page);
|
||||
let mut drop_box = None;
|
||||
if line_no == 0
|
||||
&& let Some(dc) = &drop_cap
|
||||
{
|
||||
let at = if rtl { x1 - pp.right_indent - dc.width } else { x0 + pp.left_indent };
|
||||
placed.splice(0..0, place_drop_cap(dc, at, rtl));
|
||||
drop_box = Some(DropCapBox {
|
||||
end: dc.end,
|
||||
baseline: baseline + dc.drop,
|
||||
rect: Rect::new(at, baseline - asc, at + dc.width, baseline + dc.drop + desc),
|
||||
});
|
||||
}
|
||||
ruby::annotate(&styles_tab, &mut placed);
|
||||
let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) };
|
||||
let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end);
|
||||
@@ -820,7 +882,17 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
spacing: ratio,
|
||||
hj: hj_severity(ratio, pp.word_space_min, pp.word_space_max),
|
||||
keep_violation: false,
|
||||
drop_cap: drop_box,
|
||||
});
|
||||
if drop_box.is_some() {
|
||||
drop_line = Some((cur.fi, ft.lines.len() - 1));
|
||||
}
|
||||
if let Some(dc) = &drop_cap
|
||||
&& line_no + 1 == dc.lines
|
||||
&& col_first_line == 0
|
||||
{
|
||||
drop_baseline = Some(baseline);
|
||||
}
|
||||
for k in line_notes {
|
||||
notes.place(doc, k, cur.fi, cur.col, col_w, f, opts);
|
||||
}
|
||||
@@ -868,6 +940,19 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The drop cap sits on its last line's baseline as set (it was placed at the leading's).
|
||||
if let (Some((fi, li)), Some(b), Some(n)) = (drop_line, drop_baseline, drop_cap.as_ref().map(|dc| dc.glyphs.len()))
|
||||
&& let Some(l) = out.frames.get_mut(fi).and_then(|f| f.lines.get_mut(li))
|
||||
&& let Some(dc) = l.drop_cap.as_mut()
|
||||
{
|
||||
let d = b - dc.baseline;
|
||||
dc.baseline = b;
|
||||
dc.rect.y1 += d;
|
||||
// Its glyphs start the line.
|
||||
for g in l.glyphs.iter_mut().take(n) {
|
||||
g.y += d;
|
||||
}
|
||||
}
|
||||
info.push(ParaInfo {
|
||||
start: start_at,
|
||||
at_top,
|
||||
@@ -1939,6 +2024,90 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop caps are at most this many lines and characters (larger values are capped).
|
||||
pub const MAX_DROP_CAP_LINES: u32 = 25;
|
||||
pub const MAX_DROP_CAP_CHARS: u32 = 150;
|
||||
|
||||
/// Byte where paragraph `prange`'s drop cap ends: after its first `drop_cap_chars` characters
|
||||
/// (grapheme clusters, spaces included; all of them in a shorter paragraph). `prange.start` when it
|
||||
/// has none: no lines or characters, or vertical text (which sets no drop caps).
|
||||
fn drop_cap_end(text: &str, prange: Range<usize>, pp: &ParaProps, vertical: bool) -> usize {
|
||||
use unicode_segmentation::UnicodeSegmentation as _;
|
||||
if vertical || pp.drop_cap_lines == 0 || pp.drop_cap_chars == 0 {
|
||||
return prange.start;
|
||||
}
|
||||
let Some(para) = text.get(prange.clone()) else { return prange.start };
|
||||
let para = para.trim_end_matches('\n');
|
||||
let n = pp.drop_cap_chars.min(MAX_DROP_CAP_CHARS) as usize;
|
||||
prange.start + para.grapheme_indices(true).nth(n).map_or(para.len(), |(i, _)| i)
|
||||
}
|
||||
|
||||
/// A paragraph's drop cap: its first glyphs, enlarged to reach down `lines` lines.
|
||||
struct DropCap {
|
||||
glyphs: Vec<Glyph>,
|
||||
/// Story byte it ends at.
|
||||
end: usize,
|
||||
lines: usize,
|
||||
/// Its advance: how far the lines beside it are indented.
|
||||
width: f64,
|
||||
/// Its baseline below the first line's, at the paragraph's leading.
|
||||
drop: f64,
|
||||
}
|
||||
|
||||
/// Take the drop cap (the glyphs before byte `end`) off the front of a paragraph's glyphs and scale
|
||||
/// it so its cap height reaches from the first line's cap height down to line `lines`' baseline,
|
||||
/// measured in the leading and cap height of the text after it.
|
||||
/// On a baseline grid of increment `grid`, lines are that many grid steps apart.
|
||||
fn split_drop_cap(glyphs: &mut Vec<Glyph>, end: usize, lines: u32, base_leading: f64, base_cap: f64, grid: Option<f64>) -> Option<DropCap> {
|
||||
let n = glyphs.iter().take_while(|g| g.byte < end).count();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let lines = lines.clamp(1, MAX_DROP_CAP_LINES) as usize;
|
||||
let mut dc: Vec<Glyph> = glyphs.drain(..n).collect();
|
||||
let body = glyphs.iter().find(|g| g.len > 0 && g.cap > 0.0);
|
||||
let (lead, cap) = body.map_or((base_leading, base_cap), |g| (g.leading, g.cap));
|
||||
let lead = match grid.map(|inc| (lead / inc - 1e-6).ceil().max(1.0) * inc) {
|
||||
Some(pitch) if pitch.is_finite() && pitch > 0.0 => pitch,
|
||||
_ => lead,
|
||||
};
|
||||
let drop = (lines - 1) as f64 * lead;
|
||||
let own = dc.iter().map(|g| g.cap).fold(0.0, f64::max);
|
||||
let k = if own > 0.0 { (drop + cap) / own } else { 1.0 };
|
||||
let k = if k.is_finite() && k > 0.0 { k.min(1000.0) } else { 1.0 };
|
||||
for g in &mut dc {
|
||||
g.adv *= k;
|
||||
g.dx *= k;
|
||||
g.dy *= k;
|
||||
g.sx *= k;
|
||||
g.sy *= k;
|
||||
g.ascent *= k;
|
||||
g.typo_ascent *= k;
|
||||
g.descent *= k;
|
||||
g.cap *= k;
|
||||
g.xh *= k;
|
||||
g.size *= k;
|
||||
g.space *= k;
|
||||
}
|
||||
let width = dc.iter().map(|g| g.adv).sum();
|
||||
Some(DropCap { glyphs: dc, end, lines, width, drop })
|
||||
}
|
||||
|
||||
/// The drop cap's glyphs set from `at` (right to left: mirrored, its first character on the right).
|
||||
fn place_drop_cap(dc: &DropCap, at: f64, rtl: bool) -> Vec<PlacedGlyph> {
|
||||
let mut x = at;
|
||||
let mut out = Vec::with_capacity(dc.glyphs.len());
|
||||
for g in &dc.glyphs {
|
||||
let pen = if rtl { at + at + dc.width - x - g.adv } else { x };
|
||||
let mut p = place(g, pen);
|
||||
p.y += dc.drop;
|
||||
p.rtl = rtl;
|
||||
out.push(p);
|
||||
x += g.adv;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn prepend_label(
|
||||
db: &FontDb,
|
||||
glyphs: &mut Vec<Glyph>,
|
||||
@@ -1965,6 +2134,7 @@ fn prepend_label(
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
(0, ""),
|
||||
);
|
||||
let mut pre: Vec<Glyph> = shaped
|
||||
.glyphs
|
||||
@@ -2106,6 +2276,12 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec) {
|
||||
/// Caret geometry for story byte `pos`: (frame index, x, baseline, ascent, descent).
|
||||
pub fn caret(cs: &ComposedStory, pos: usize) -> Option<(usize, f64, f64, f64, f64)> {
|
||||
let (fi, l) = caret_line(cs, pos)?;
|
||||
// In the drop cap the caret is as tall as it.
|
||||
if let Some(dc) = l.drop_cap
|
||||
&& pos < dc.end
|
||||
{
|
||||
return Some((fi, caret_x(l, pos), dc.baseline, dc.baseline - dc.rect.y0, l.descent));
|
||||
}
|
||||
Some((fi, caret_x(l, pos), l.baseline, l.ascent, l.descent))
|
||||
}
|
||||
|
||||
@@ -2185,6 +2361,16 @@ pub fn hit(cs: &ComposedStory, fi: usize, p: Point) -> Option<usize> {
|
||||
if ft.lines.is_empty() {
|
||||
return Some(ft.range.start);
|
||||
}
|
||||
// On a drop cap (it reaches down beside the lines after its own): the nearest caret position in it.
|
||||
for l in &ft.lines {
|
||||
let Some(dc) = l.drop_cap.filter(|dc| dc.rect.contains(p)) else { continue };
|
||||
return l
|
||||
.glyphs
|
||||
.iter()
|
||||
.filter(|g| g.len > 0 && g.byte < dc.end)
|
||||
.flat_map(|g| [g.byte, g.byte + g.len])
|
||||
.min_by(|a, b| (caret_x(l, *a) - p.x).abs().total_cmp(&(caret_x(l, *b) - p.x).abs()));
|
||||
}
|
||||
// Closest line vertically (within its column).
|
||||
let l = ft
|
||||
.lines
|
||||
|
||||
@@ -22,12 +22,15 @@ fn is_event(u: &NestedUntil, c: char, prev: Option<char>, next: Option<char>) ->
|
||||
NestedUntil::ForcedLineBreak => c == designcraft_doc::FORCED_LINE_BREAK,
|
||||
NestedUntil::EmSpace => c == '\u{2003}',
|
||||
NestedUntil::EnSpace => c == '\u{2002}',
|
||||
// Counted by position, not by character (see `overlays`).
|
||||
NestedUntil::Dropcap => false,
|
||||
NestedUntil::Chars(s) => s.contains(c),
|
||||
}
|
||||
}
|
||||
|
||||
/// Character-style ranges for the paragraph `text[range]`, in application order (later wins).
|
||||
pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle], grep: &[GrepStyle]) -> Vec<(Range<usize>, String)> {
|
||||
/// The paragraph's drop cap ends at `drop_end` (`range.start` without one).
|
||||
pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle], grep: &[GrepStyle], drop_end: usize) -> Vec<(Range<usize>, String)> {
|
||||
let mut out = Vec::new();
|
||||
let para = &text[range.clone()];
|
||||
// The paragraph's own end mark isn't styled.
|
||||
@@ -37,17 +40,22 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle],
|
||||
if pos >= end {
|
||||
break;
|
||||
}
|
||||
let mut count = 0;
|
||||
let mut stop = end;
|
||||
let chars: Vec<(usize, char)> = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).collect();
|
||||
for (k, &(i, c)) in chars.iter().enumerate() {
|
||||
let prev = if k > 0 { Some(chars[k - 1].1) } else { None };
|
||||
let next = chars.get(k + 1).map(|x| x.1);
|
||||
if is_event(&ns.until, c, prev, next) {
|
||||
count += 1;
|
||||
if count >= ns.count.max(1) {
|
||||
stop = if ns.through { i + c.len_utf8() } else { i };
|
||||
break;
|
||||
if ns.until == NestedUntil::Dropcap {
|
||||
// Through the drop cap runs to its end; up to it (it starts the paragraph) is nothing.
|
||||
stop = if ns.through { drop_end.max(pos).min(end) } else { pos };
|
||||
} else {
|
||||
let mut count = 0;
|
||||
let chars: Vec<(usize, char)> = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).collect();
|
||||
for (k, &(i, c)) in chars.iter().enumerate() {
|
||||
let prev = if k > 0 { Some(chars[k - 1].1) } else { None };
|
||||
let next = chars.get(k + 1).map(|x| x.1);
|
||||
if is_event(&ns.until, c, prev, next) {
|
||||
count += 1;
|
||||
if count >= ns.count.max(1) {
|
||||
stop = if ns.through { i + c.len_utf8() } else { i };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,14 +92,21 @@ mod tests {
|
||||
let text = "Q: what is 42? A drop of 7 words.\n".to_string();
|
||||
let n = |style: &str, through: bool, count: u32, until: NestedUntil| NestedStyle { style: style.into(), through, count, until };
|
||||
// Through the first ':' then up to the second word end.
|
||||
let o = overlays(&text, 0..text.len(), &[n("Lead", true, 1, NestedUntil::Chars(":".into())), n("Next", false, 2, NestedUntil::Words)], &[]);
|
||||
let o =
|
||||
overlays(&text, 0..text.len(), &[n("Lead", true, 1, NestedUntil::Chars(":".into())), n("Next", false, 2, NestedUntil::Words)], &[], 0);
|
||||
assert_eq!(&text[o[0].0.clone()], "Q:");
|
||||
assert_eq!(&text[o[1].0.clone()], " what is", "up to the end of the second word");
|
||||
// Digits by GREP.
|
||||
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "Num".into(), pattern: r"\d+".into() }]);
|
||||
let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "Num".into(), pattern: r"\d+".into() }], 0);
|
||||
let got: Vec<&str> = o.iter().map(|(r, _)| &text[r.clone()]).collect();
|
||||
assert_eq!(got, ["42", "7"]);
|
||||
// Through the drop cap (its first 2 bytes here); up to it is nothing; without one, nothing.
|
||||
let d = |through: bool| n("Cap", through, 1, NestedUntil::Dropcap);
|
||||
let o = overlays(&text, 0..text.len(), &[d(true), n("Next", true, 1, NestedUntil::Words)], &[], 2);
|
||||
assert_eq!((&text[o[0].0.clone()], &text[o[1].0.clone()]), ("Q:", " what "));
|
||||
assert_eq!(&text[overlays(&text, 0..text.len(), &[d(false), n("Next", true, 1, NestedUntil::Words)], &[], 2)[0].0.clone()], "Q: ");
|
||||
assert!(overlays(&text, 0..text.len(), &[d(true)], &[], 0).is_empty());
|
||||
// A bad pattern is ignored.
|
||||
assert!(overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "X".into(), pattern: "(".into() }]).is_empty());
|
||||
assert!(overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "X".into(), pattern: "(".into() }], 0).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,8 @@ impl StyleTable<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve and shape one paragraph.
|
||||
/// Resolve and shape one paragraph. Its drop cap ends at byte `drop.0` (`range.start` without
|
||||
/// one) and takes the character style `drop.1` ("" = none).
|
||||
pub(crate) fn shape_para(
|
||||
db: &FontDb,
|
||||
styles: &Styles,
|
||||
@@ -170,13 +171,18 @@ pub(crate) fn shape_para(
|
||||
nested: &[designcraft_doc::NestedStyle],
|
||||
grep: &[designcraft_doc::GrepStyle],
|
||||
lines: &[(std::ops::Range<usize>, String)],
|
||||
drop: (usize, &str),
|
||||
) -> ShapedPara {
|
||||
let _ = pi;
|
||||
let mut glyphs = Vec::with_capacity(range.len());
|
||||
// Nested line styles lie under nested and GREP styles (later overlays win).
|
||||
// Nested line styles lie under nested and GREP styles, and the drop cap's style over them all
|
||||
// (later overlays win).
|
||||
let mut overlays: Vec<(std::ops::Range<usize>, String)> = lines.to_vec();
|
||||
if !nested.is_empty() || !grep.is_empty() {
|
||||
overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep));
|
||||
overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep, drop.0));
|
||||
}
|
||||
if drop.0 > range.start && !drop.1.is_empty() && drop.1 != designcraft_doc::NO_CHAR_STYLE {
|
||||
overlays.push((range.start..drop.0, drop.1.to_string()));
|
||||
}
|
||||
// Each run, cut where nested / GREP styles start and end.
|
||||
let mut segments: Vec<(usize, usize, Option<&str>, &designcraft_doc::CharFormat)> = Vec::new();
|
||||
|
||||
@@ -410,6 +410,7 @@ fn emit(
|
||||
spacing: 1.0,
|
||||
hj: 0,
|
||||
keep_violation: false,
|
||||
drop_cap: None,
|
||||
});
|
||||
ft.tables.push(TableFrag {
|
||||
table: table.id,
|
||||
|
||||
+247
-3
@@ -1445,17 +1445,18 @@ fn cap_height_and_x_height_first_baselines_use_the_fonts_cap_and_x_heights() {
|
||||
fn cap_height_and_x_height_fall_back_without_usable_os2_values() {
|
||||
use designcraft_doc::FirstBaseline;
|
||||
let db = designcraft_fonts::FontDb::global();
|
||||
// No OS/2 table, zero / negative and absurd values: 0.72 and 0.5 of the (1 em) ascent.
|
||||
// No OS/2 table, zero / negative and absurd values: the top of the H (0.656 em) and 0.5 of the
|
||||
// (1 em) ascent.
|
||||
let zero: &[(usize, i16)] = &[(CAP_HEIGHT, 0), (X_HEIGHT, -40)];
|
||||
let big: &[(usize, i16)] = &[(CAP_HEIGHT, i16::MAX), (X_HEIGHT, i16::MAX)];
|
||||
for (family, fields) in [("CapXHeightNoO", None), ("CapXHeightNeg", Some(zero)), ("CapXHeightBig", Some(big))] {
|
||||
db.add_font(test_font(family, fields));
|
||||
let face = db.face(family, "Regular");
|
||||
assert_eq!(face.family, family);
|
||||
assert_eq!((face.cap_height, face.x_height), (face.ascent * 0.72, face.ascent * 0.5), "{family}");
|
||||
assert_eq!((face.cap_height, face.x_height), (656.0, face.ascent * 0.5), "{family}");
|
||||
let cap = first_baseline_in(family, |o| o.first_baseline = FirstBaseline::CapHeight);
|
||||
let x = first_baseline_in(family, |o| o.first_baseline = FirstBaseline::XHeight);
|
||||
assert!((cap - (40.0 + 14.4)).abs() < 0.01 && (x - (40.0 + 10.0)).abs() < 0.01, "{family}: {cap} {x}");
|
||||
assert!((cap - (40.0 + 13.12)).abs() < 0.01 && (x - (40.0 + 10.0)).abs() < 0.01, "{family}: {cap} {x}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1475,3 +1476,246 @@ fn vertical_frames_keep_the_cap_height_and_x_height_first_baselines() {
|
||||
assert!((at(FirstBaseline::CapHeight) - 14.4).abs() < 0.01, "{}", at(FirstBaseline::CapHeight));
|
||||
assert!((at(FirstBaseline::XHeight) - 10.0).abs() < 0.01, "{}", at(FirstBaseline::XHeight));
|
||||
}
|
||||
|
||||
// ---------- drop caps ----------
|
||||
|
||||
/// `text` set 10 pt on 12 pt leading in a 300 pt measure, with `para` on top.
|
||||
fn drop_doc(text: &str, para: ParaAttrs) -> (Document, StoryId, ItemId) {
|
||||
drop_doc_in(text, Rect::new(0.0, 0.0, 300.0, 1000.0), para)
|
||||
}
|
||||
|
||||
fn drop_doc_in(text: &str, rect: Rect, para: ParaAttrs) -> (Document, StoryId, ItemId) {
|
||||
let (mut d, sid, fid) = doc_with(text, rect, para);
|
||||
for p in &mut d.story_mut(sid).unwrap().paras {
|
||||
p.chars = designcraft_doc::CharAttrs { size: Some(10.0), leading: Some(designcraft_doc::Leading::Points(12.0)), ..Default::default() };
|
||||
}
|
||||
(d, sid, fid)
|
||||
}
|
||||
|
||||
fn drop_cap(lines: u32, chars: u32) -> ParaAttrs {
|
||||
ParaAttrs { drop_cap_lines: Some(lines), drop_cap_chars: Some(chars), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Cap height of a placed glyph, in points.
|
||||
fn cap_of(g: &PlacedGlyph) -> f64 {
|
||||
g.face.cap_height * g.sy
|
||||
}
|
||||
|
||||
/// x of the first glyph of `l` set from story byte `from` on.
|
||||
fn text_x(l: &Line, from: usize) -> f64 {
|
||||
l.glyphs.iter().find(|g| g.len > 0 && g.byte >= from).map(|g| g.x).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_cap_spans_lines_and_indents_them() {
|
||||
let text = [LOREM; 2].join(" ");
|
||||
let (plain, psid, _) = drop_doc(&text, ParaAttrs::default());
|
||||
let plain = compose_story(&plain, psid, &ComposeOptions::default());
|
||||
let (d, sid, _) = drop_doc(&text, drop_cap(3, 1));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
let pl = all_lines(&plain);
|
||||
assert!(lines.len() > 4);
|
||||
// Baselines don't move.
|
||||
for (k, l) in lines.iter().enumerate() {
|
||||
assert!((l.baseline - (pl[0].baseline + 12.0 * k as f64)).abs() < 1e-6, "line {k} at {}", l.baseline);
|
||||
}
|
||||
// The drop cap: the first glyph, on line 1, its baseline on line 3's and its cap top on line 1's.
|
||||
let dc = &lines[0].glyphs[0];
|
||||
assert_eq!((dc.byte, dc.len), (0, 1));
|
||||
assert!((lines[0].baseline + dc.y - lines[2].baseline).abs() < 1e-6, "drop cap baseline {}", lines[0].baseline + dc.y);
|
||||
let body = lines[0].glyphs.iter().find(|g| g.byte == 1).unwrap();
|
||||
let top = lines[2].baseline - cap_of(dc);
|
||||
assert!((top - (lines[0].baseline - cap_of(body))).abs() < 0.01, "cap top {top}");
|
||||
assert!(dc.sy > body.sy * 3.0);
|
||||
// Lines 1–3 start after it, line 4 at the frame edge.
|
||||
let right = dc.x + dc.adv;
|
||||
assert!(right > 20.0);
|
||||
for l in &lines[..3] {
|
||||
assert!(text_x(l, 1) >= right - 1e-6, "{} < {right}", text_x(l, 1));
|
||||
assert!(l.end_x <= l.x1 + 0.5);
|
||||
}
|
||||
assert!(text_x(lines[3], 1) < 0.5);
|
||||
// The text stays in order: the drop cap starts line 1 (exports and caret read it there).
|
||||
assert_eq!(lines[0].range.start, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_cap_of_two_characters_as_a_local_override() {
|
||||
let (d, sid, _) = drop_doc(LOREM, drop_cap(2, 2));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
let (a, b) = (&lines[0].glyphs[0], &lines[0].glyphs[1]);
|
||||
assert_eq!((a.byte, b.byte), (0, 1));
|
||||
let body = lines[0].glyphs.iter().find(|g| g.byte == 2).unwrap();
|
||||
for g in [a, b] {
|
||||
assert!((lines[0].baseline + g.y - lines[1].baseline).abs() < 1e-6);
|
||||
assert!(g.sy > body.sy * 1.5);
|
||||
}
|
||||
assert!((b.x - (a.x + a.adv)).abs() < 1e-6, "the drop cap characters sit side by side");
|
||||
let right = b.x + b.adv;
|
||||
assert!(text_x(lines[0], 2) >= right - 1e-6 && text_x(lines[1], 2) >= right - 1e-6);
|
||||
assert!(text_x(lines[2], 2) < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_paragraph_keeps_its_drop_cap_and_the_next_paragraph_is_not_indented() {
|
||||
let (mut d, sid, _) = drop_doc(&format!("Short.\n{LOREM}"), drop_cap(3, 1));
|
||||
d.story_mut(sid).unwrap().paras[1].para = ParaAttrs::default();
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert_eq!(lines.iter().filter(|l| l.para == 0).count(), 1);
|
||||
let dc = &lines[0].glyphs[0];
|
||||
// Still three lines tall (it may overlap the next paragraph, which isn't indented).
|
||||
assert!((dc.y - 24.0).abs() < 1e-6, "{}", dc.y);
|
||||
assert!(text_x(lines[1], 7) < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_cap_counts_grapheme_clusters_and_clamps_to_the_paragraph() {
|
||||
// An accented letter written with a combining mark is one character.
|
||||
let (d, sid, _) = drop_doc("e\u{301}tude and more words", drop_cap(2, 1));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let l = &cs.frames[0].lines[0];
|
||||
let big: Vec<usize> = l.glyphs.iter().filter(|g| g.y > 1.0).map(|g| g.byte).collect();
|
||||
assert!(!big.is_empty() && big.iter().all(|b| *b < 3), "{big:?}");
|
||||
assert!(l.glyphs.iter().any(|g| g.byte == 0 && g.y > 1.0));
|
||||
// More characters than the paragraph has: all of them, and nothing of the next paragraph.
|
||||
let (mut d, sid, _) = drop_doc("Hi\nNext", drop_cap(2, 50));
|
||||
d.story_mut(sid).unwrap().paras[1].para = ParaAttrs::default();
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert!(lines[0].glyphs.iter().filter(|g| g.len > 0).all(|g| g.y > 1.0));
|
||||
assert!(lines[1].glyphs.iter().all(|g| g.y.abs() < 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_drop_cap_values_do_not_panic() {
|
||||
for (lines, chars) in [(u32::MAX, u32::MAX), (1_000_000, 1), (2, 0), (0, 3)] {
|
||||
let (d, sid, _) = drop_doc(LOREM, drop_cap(lines, chars));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let g = &cs.frames[0].lines[0].glyphs[0];
|
||||
assert!(g.y.is_finite() && g.sy.is_finite() && g.y <= 12.0 * 25.0, "{lines}/{chars}: {}", g.y);
|
||||
}
|
||||
// No lines or no characters: no drop cap.
|
||||
let (d, sid, _) = drop_doc(LOREM, drop_cap(0, 3));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!(cs.frames[0].lines[0].glyphs[0].y.abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_line_drop_cap_is_not_enlarged() {
|
||||
let (d, sid, _) = drop_doc(LOREM, drop_cap(1, 1));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let l = &cs.frames[0].lines[0];
|
||||
let (dc, body) = (&l.glyphs[0], l.glyphs.iter().find(|g| g.byte == 1).unwrap());
|
||||
assert!((cap_of(dc) - cap_of(body)).abs() < 0.01 && dc.y.abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_cap_stays_with_the_first_line_across_a_column_break() {
|
||||
// Two lines fit a column: line 3 starts the second column, at its edge.
|
||||
let (mut d, sid, fid) = drop_doc_in(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 400.0, 30.0), drop_cap(3, 1));
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let ft = &cs.frames[0];
|
||||
let col1: Vec<&Line> = ft.lines.iter().filter(|l| l.column == 1).collect();
|
||||
assert_eq!(ft.lines.iter().filter(|l| l.column == 0).count(), 2, "{:?}", ft.lines.iter().map(|l| l.column).collect::<Vec<_>>());
|
||||
let dc = &ft.lines[0].glyphs[0];
|
||||
assert!((dc.y - 24.0).abs() < 1e-6, "{}", dc.y);
|
||||
assert!(text_x(&ft.lines[1], 1) >= dc.x + dc.adv - 1e-6);
|
||||
assert!((text_x(col1[0], 1) - ft.columns[1].x0).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_cap_in_right_to_left_paragraphs_is_on_the_right() {
|
||||
let para = ParaAttrs { direction: Some(designcraft_doc::TextDirection::RightToLeft), align: Some(Align::Right), ..drop_cap(2, 1) };
|
||||
let (d, sid, _) = drop_doc(LOREM, para);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
let dc = lines[0].glyphs.iter().find(|g| g.byte == 0).unwrap();
|
||||
assert!((dc.x + dc.adv - 300.0).abs() < 0.5, "{}", dc.x + dc.adv);
|
||||
for l in &lines[..2] {
|
||||
assert!(l.glyphs.iter().filter(|g| g.byte > 0 && g.visible).all(|g| g.x + g.adv <= dc.x + 0.5));
|
||||
}
|
||||
assert!(lines[2].end_x > 299.0 || lines[2].glyphs.iter().any(|g| g.x + g.adv > 299.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_frames_set_no_drop_cap() {
|
||||
let (mut d, sid, fid) = drop_doc(LOREM, drop_cap(3, 1));
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical = true;
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!(cs.frames[0].lines[0].glyphs[0].y.abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_cap_takes_its_character_style_and_nested_styles_count_it() {
|
||||
let (mut d, sid, _) = drop_doc(LOREM, ParaAttrs { drop_cap_style: Some("Initial".into()), ..drop_cap(2, 3) });
|
||||
for (name, fill) in [("Initial", "Initial Red"), ("Lead", "Lead Blue")] {
|
||||
std::sync::Arc::make_mut(&mut d.styles).character.push(designcraft_doc::CharacterStyle {
|
||||
name: name.into(),
|
||||
based_on: None,
|
||||
chars: designcraft_doc::CharAttrs { fill: Some(fill.into()), ..Default::default() },
|
||||
shortcut: String::new(),
|
||||
});
|
||||
}
|
||||
let fill = |cs: &ComposedStory, g: &PlacedGlyph| cs.styles[g.style as usize].fill.clone();
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let l = &cs.frames[0].lines[0];
|
||||
let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap();
|
||||
assert_eq!(fill(&cs, at(0)), "Initial Red");
|
||||
assert_eq!(fill(&cs, at(2)), "Initial Red");
|
||||
assert_eq!(fill(&cs, at(3)), "[Black]");
|
||||
// A nested style "through 1 drop cap" covers the drop cap; the next one starts after it.
|
||||
let ns = |style: &str, until: designcraft_doc::NestedUntil| designcraft_doc::NestedStyle { style: style.into(), through: true, count: 1, until };
|
||||
d.story_mut(sid).unwrap().paras[0].para = ParaAttrs {
|
||||
nested_styles: Some(vec![ns("Lead", designcraft_doc::NestedUntil::Dropcap), ns("Initial", designcraft_doc::NestedUntil::Words)]),
|
||||
..drop_cap(2, 3)
|
||||
};
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let l = &cs.frames[0].lines[0];
|
||||
let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap();
|
||||
assert_eq!(fill(&cs, at(0)), "Lead Blue");
|
||||
assert_eq!(fill(&cs, at(2)), "Lead Blue");
|
||||
assert_eq!(fill(&cs, at(3)), "Initial Red", "the second nested style starts after the drop cap");
|
||||
let first_space = LOREM.find(' ').unwrap();
|
||||
assert_eq!(fill(&cs, at(first_space + 1)), "[Black]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_cap_caret_and_hit_testing() {
|
||||
let (d, sid, _) = drop_doc(LOREM, drop_cap(3, 1));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
let dc = lines[0].glyphs[0].clone();
|
||||
let drop_box = lines[0].drop_cap.unwrap().rect;
|
||||
assert!(drop_box.y1 > lines[2].baseline - 0.01 && drop_box.y0 < lines[0].baseline - 5.0, "{drop_box:?}");
|
||||
// A click on the lower half of the big letter (beside line 3) is at the drop cap.
|
||||
let y = lines[2].baseline - 2.0;
|
||||
assert_eq!(hit(&cs, 0, Point::new(dc.x + dc.adv * 0.25, y)), Some(0));
|
||||
assert_eq!(hit(&cs, 0, Point::new(dc.x + dc.adv * 0.75, y)), Some(1));
|
||||
// The caret before the drop cap is as tall as it; after it, beside line 1's text.
|
||||
let (_, x, bl, asc, _) = caret(&cs, 0).unwrap();
|
||||
assert!((x - dc.x).abs() < 1e-6 && (bl - lines[2].baseline).abs() < 1e-6 && asc > 24.0, "{x} {bl} {asc}");
|
||||
let (_, x, bl, _, _) = caret(&cs, 1).unwrap();
|
||||
assert!(x >= dc.x + dc.adv - 1e-6 && (bl - lines[0].baseline).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_cap_sits_on_its_line_as_set() {
|
||||
// Lines on a 15 pt baseline grid (not the 12 pt leading): the drop cap follows line 3.
|
||||
let (mut d, sid, _) = drop_doc(LOREM, ParaAttrs { grid_align: Some(designcraft_doc::GridAlign::AllLines), ..drop_cap(3, 1) });
|
||||
d.settings.baseline_grid.increment = 15.0;
|
||||
d.settings.baseline_grid.start = 0.0;
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert!((lines[1].baseline - lines[0].baseline - 15.0).abs() < 1e-6);
|
||||
let dc = &lines[0].glyphs[0];
|
||||
assert!((lines[0].baseline + dc.y - lines[2].baseline).abs() < 1e-6);
|
||||
let b = lines[0].drop_cap.unwrap();
|
||||
assert!((b.baseline - lines[2].baseline).abs() < 1e-6 && b.rect.y1 > lines[2].baseline);
|
||||
// Scaled to the grid's line pitch: its cap top is still line 1's.
|
||||
let body = lines[0].glyphs.iter().find(|g| g.byte == 1).unwrap();
|
||||
assert!((lines[2].baseline - cap_of(dc) - (lines[0].baseline - cap_of(body))).abs() < 0.01);
|
||||
}
|
||||
|
||||
@@ -289,6 +289,8 @@ pub enum NestedUntil {
|
||||
ForcedLineBreak,
|
||||
EmSpace,
|
||||
EnSpace,
|
||||
/// The paragraph's drop cap characters.
|
||||
Dropcap,
|
||||
/// Any of these characters.
|
||||
Chars(String),
|
||||
}
|
||||
@@ -562,8 +564,12 @@ attr_set! {
|
||||
last_line_indent: f64 = 0.0,
|
||||
space_before: f64 = 0.0,
|
||||
space_after: f64 = 0.0,
|
||||
/// Drop cap: the paragraph's first `drop_cap_chars` characters (grapheme clusters) set
|
||||
/// `drop_cap_lines` lines tall, in the character style `drop_cap_style` ("" or `[None]`: the
|
||||
/// text's own formatting).
|
||||
drop_cap_lines: u32 = 0,
|
||||
drop_cap_chars: u32 = 0,
|
||||
drop_cap_style: String = String::new(),
|
||||
/// Nested styles, in order from the paragraph start.
|
||||
nested_styles: Vec<NestedStyle> = Vec::new(),
|
||||
/// GREP styles: a character style for every match of a pattern (applied after nested).
|
||||
|
||||
@@ -115,7 +115,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paragraph Formatting",
|
||||
[],
|
||||
None,
|
||||
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, hyphenate?, composer?, tabs?, …}}",
|
||||
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, hyphenate?, composer?, tabs?, …}}",
|
||||
has_text_or_frames,
|
||||
|s, p| format_paras(s, p.get("attrs").unwrap_or(p))
|
||||
),
|
||||
|
||||
@@ -251,3 +251,27 @@ fn named_lists_round_trip_through_idml() {
|
||||
assert_eq!(st.paras[0].para.list_name.as_deref(), Some("Steps"));
|
||||
assert_eq!(st.paras[0].para.start_at, Some(Some(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_para_sets_a_drop_cap_that_survives_idml() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("style.character.create", &json!({"name": "Initial", "chars": {"fontStyle": "Bold"}})).unwrap();
|
||||
let text = "Once upon a time there was a paragraph long enough to run beside its drop cap for a few lines and then some more.";
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": text})).unwrap();
|
||||
let sid = r["story"].as_u64().unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap();
|
||||
s.execute("type.para", &json!({"attrs": {"dropCapLines": 3, "dropCapChars": 1, "dropCapStyle": "Initial"}})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let cs = designcraft_compose::compose_story(&d, designcraft_doc::StoryId(sid), &Default::default());
|
||||
let lines = &cs.frames[0].lines;
|
||||
let dc = &lines[0].glyphs[0];
|
||||
assert_eq!(dc.byte, 0);
|
||||
assert!((lines[0].baseline + dc.y - lines[2].baseline).abs() < 1e-6, "on line 3's baseline");
|
||||
assert!(lines[0].drop_cap.is_some_and(|b| b.end == 1));
|
||||
let p = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0].para;
|
||||
assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_style.as_deref()), (Some(3), Some(1), Some("Initial")));
|
||||
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap();
|
||||
let p = &back.stories.values().find(|st| st.text.starts_with("Once")).unwrap().paras[0].para;
|
||||
assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_style.as_deref()), (Some(3), Some(1), Some("Initial")));
|
||||
}
|
||||
|
||||
@@ -63,8 +63,9 @@ pub struct FontFace {
|
||||
pub typo_ascent: f64,
|
||||
/// Descender in font units (positive = down).
|
||||
pub descent: f64,
|
||||
/// Cap height and x height in font units (estimated from the ascent when the font has no
|
||||
/// plausible OS/2 values): the Cap Height and x Height first baseline offsets.
|
||||
/// Cap height and x height in font units (without plausible OS/2 values, the cap height is the
|
||||
/// top of the H, and either is estimated from the ascent): the Cap Height and x Height first
|
||||
/// baseline offsets.
|
||||
pub cap_height: f64,
|
||||
pub x_height: f64,
|
||||
pub shaper: harfrust::ShaperData,
|
||||
@@ -455,7 +456,13 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
ascent: m.ascent as f64,
|
||||
typo_ascent: typo_ascent(&f, &location, upem).unwrap_or(m.ascent as f64),
|
||||
descent: -(m.descent as f64),
|
||||
cap_height: plausible(m.cap_height.map(f64::from), upem).unwrap_or(m.ascent as f64 * 0.72),
|
||||
cap_height: plausible(m.cap_height.map(f64::from), upem)
|
||||
.or_else(|| {
|
||||
// No OS/2 cap height: the top of the H.
|
||||
let gid = f.charmap().map('H')?;
|
||||
plausible(f.glyph_metrics(Size::unscaled(), &location).bounds(gid).map(|b| b.y_max as f64), upem)
|
||||
})
|
||||
.unwrap_or(m.ascent as f64 * 0.72),
|
||||
x_height: plausible(m.x_height.map(f64::from), upem).unwrap_or(m.ascent as f64 * 0.5),
|
||||
shaper,
|
||||
coords,
|
||||
|
||||
@@ -1150,6 +1150,10 @@ impl<'a> Ex<'a> {
|
||||
n!(space_after, "SpaceAfter");
|
||||
n!(drop_cap_lines, "DropCapLines");
|
||||
n!(drop_cap_chars, "DropCapCharacters");
|
||||
if let Some(s) = &a.drop_cap_style {
|
||||
let s = if s.is_empty() { designcraft_doc::NO_CHAR_STYLE } else { s.as_str() };
|
||||
el.set("DropCapStyle", names::style_self("CharacterStyle", CHAR_BUILTINS, s));
|
||||
}
|
||||
if let Some(g) = a.grid_align {
|
||||
match g {
|
||||
designcraft_doc::GridAlign::None => el.set("GridAlignment", "None"),
|
||||
|
||||
@@ -1156,6 +1156,7 @@ impl<'r> Importer<'r> {
|
||||
a.space_after = e.num("SpaceAfter");
|
||||
a.drop_cap_lines = u("DropCapLines");
|
||||
a.drop_cap_chars = u("DropCapCharacters");
|
||||
a.drop_cap_style = e.prop("DropCapStyle").map(|r| self.char_style_ref(r.trim()));
|
||||
if let Some(g) = e.prop("GridAlignment") {
|
||||
a.grid_align = Some(if g == "None" {
|
||||
GridAlign::None
|
||||
|
||||
@@ -597,6 +597,7 @@ pub fn nested_until_out(u: &designcraft_doc::NestedUntil) -> (&'static str, Stri
|
||||
N::ForcedLineBreak => e("ForcedLineBreak"),
|
||||
N::EmSpace => e("EmSpaces"),
|
||||
N::EnSpace => e("EnSpaces"),
|
||||
N::Dropcap => e("Dropcap"),
|
||||
N::Chars(c) => ("string", c.clone()),
|
||||
}
|
||||
}
|
||||
@@ -615,6 +616,7 @@ pub fn nested_until_in(ty: &str, v: &str) -> designcraft_doc::NestedUntil {
|
||||
"ForcedLineBreak" => N::ForcedLineBreak,
|
||||
"EmSpaces" => N::EmSpace,
|
||||
"EnSpaces" => N::EnSpace,
|
||||
"Dropcap" => N::Dropcap,
|
||||
_ => N::Words,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,3 +560,46 @@ fn vertical_story_orientation_round_trips() {
|
||||
let frame = back.spreads[0].items.iter().find_map(|i| i.text_frame()).unwrap();
|
||||
assert!(frame.options.vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_caps_import_and_round_trip() {
|
||||
// A style with a drop cap, its character style and a nested style through it; a paragraph
|
||||
// overriding it locally.
|
||||
let opener = r#"<ParagraphStyle Self="ParagraphStyle/Opener" Name="Opener" DropCapCharacters="1" DropCapLines="3">
|
||||
<Properties><DropCapStyle type="object">CharacterStyle/Strong</DropCapStyle><AllNestedStyles type="list"><ListItem type="record">
|
||||
<AppliedCharacterStyle type="object">CharacterStyle/Strong</AppliedCharacterStyle><Delimiter type="enumeration">Dropcap</Delimiter>
|
||||
<Repetition type="long">1</Repetition><Inclusive type="boolean">true</Inclusive></ListItem></AllNestedStyles></Properties>
|
||||
</ParagraphStyle>
|
||||
</RootParagraphStyleGroup>"#;
|
||||
let styles = STYLES.replace("</RootParagraphStyleGroup>", opener);
|
||||
let story = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
|
||||
<Story Self="s1">
|
||||
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Opener" DropCapCharacters="2" DropCapLines="2" DropCapStyle="CharacterStyle/$ID/[No character style]">
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Once upon a time</Content></CharacterStyleRange>
|
||||
</ParagraphStyleRange>
|
||||
</Story>
|
||||
</idPkg:Story>"#;
|
||||
let bytes = zip_files(&[
|
||||
("designmap.xml", DESIGNMAP),
|
||||
("Resources/Graphic.xml", GRAPHIC),
|
||||
("Resources/Styles.xml", &styles),
|
||||
("Resources/Preferences.xml", PREFS),
|
||||
("MasterSpreads/MasterSpread_m1.xml", MASTER),
|
||||
("Spreads/Spread_sp1.xml", SPREAD),
|
||||
("Stories/Story_s1.xml", story),
|
||||
]);
|
||||
let check = |d: &Document| {
|
||||
let st = d.styles.para("Opener").unwrap();
|
||||
assert_eq!((st.para.drop_cap_chars, st.para.drop_cap_lines), (Some(1), Some(3)));
|
||||
assert_eq!(st.para.drop_cap_style.as_deref(), Some("Strong"));
|
||||
let ns = st.para.nested_styles.clone().unwrap();
|
||||
assert_eq!((ns[0].style.as_str(), &ns[0].until), ("Strong", &designcraft_doc::NestedUntil::Dropcap));
|
||||
let p = &d.stories.values().find(|s| s.text.starts_with("Once")).unwrap().paras[0];
|
||||
assert_eq!((p.para.drop_cap_chars, p.para.drop_cap_lines), (Some(2), Some(2)));
|
||||
assert_eq!(p.para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE));
|
||||
};
|
||||
let d = import_idml_with(&bytes, &|_| None).unwrap();
|
||||
check(&d);
|
||||
check(&import_idml(&export_idml(&d)).unwrap());
|
||||
}
|
||||
|
||||
@@ -201,3 +201,15 @@ fn tables_export_cell_text() {
|
||||
assert!(text.contains(w), "{w} missing from {text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_cap_text_extracts_in_order() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let para = designcraft_doc::ParaAttrs { drop_cap_lines: Some(3), drop_cap_chars: Some(2), ..Default::default() };
|
||||
let text = "Drop caps read in order with the rest of the paragraph, which runs on for a few lines beside the enlarged letters.";
|
||||
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 236.0, 300.0), lid, text, ParaFormat { para, ..Default::default() }).unwrap();
|
||||
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
|
||||
let flat: String = extract_text(&bytes)[0].split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
assert!(flat.starts_with("Drop caps read in order"), "{flat}");
|
||||
}
|
||||
|
||||
@@ -174,7 +174,9 @@ fn line_box(l: &Line) -> Option<Rect> {
|
||||
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);
|
||||
Some(Rect::new(x0 - pad, l.baseline - l.ascent - pad, x1 + pad, l.baseline + l.descent.max(0.0) + pad))
|
||||
// A drop cap reaches down past its line.
|
||||
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))
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
|
||||
+3
-2
@@ -5,12 +5,12 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of
|
||||
Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded.
|
||||
|
||||
<!-- SUMMARY -->
|
||||
**Weighted breadth parity: 99%** over 275 features.
|
||||
**Weighted breadth parity: 99%** over 276 features.
|
||||
|
||||
| Priority | Features | Done | Partial | Missing | Parity |
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 102 | 100 | 2 | 0 | 99% |
|
||||
| P1 | 97 | 97 | 0 | 0 | 100% |
|
||||
| P1 | 98 | 98 | 0 | 0 | 100% |
|
||||
| P2 | 76 | 72 | 4 | 0 | 97% |
|
||||
|
||||
| Area | Parity |
|
||||
@@ -224,6 +224,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print)
|
||||
| Typography | Single-line Composer | P0 | D |
|
||||
| Typography | Tabs, leaders, right-indent tab | P0 | D |
|
||||
| Typography | Nested / line / GREP styles | P1 | D |
|
||||
| Typography | Drop caps | P1 | D |
|
||||
| Typography | Paragraph rules | P1 | D |
|
||||
| Typography | Paragraph borders & shading | P1 | D |
|
||||
| Typography | Balance ragged lines | P1 | D |
|
||||
|
||||
Reference in New Issue
Block a user