mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
Set a text selection as warichu inside the parent line (#280)
* Set warichu as smaller lines inside the parent line. Type › Warichu stacks the selected run after the line is broken, closes up the rest of the line, and round-trips the IDML warichu properties. The line break itself still sees the full-size run. * Keep clicks, the caret, and Up/Down on each warichu row. The glyphs were already stacked inside the parent line, but hit testing, the caret, and vertical movement still used that parent line. Break minimums, row widths, spacing, and tab leaders now follow the small lines, and underlines sit on the small baseline. --------- Co-authored-by: Brandon Thomas <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
19 files changed
+935
-43
No files matched your search
+2
-2
@@ -5,7 +5,7 @@
|
||||
- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
|
||||
|
||||
- AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.
|
||||
- CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby/warichu remain unfinished.
|
||||
- CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby remain unfinished. Warichu sets the selected run in smaller lines inside the line after the line is broken.
|
||||
|
||||
DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web.
|
||||
|
||||
@@ -192,7 +192,7 @@ four agents in parallel on separate crates:
|
||||
|---|---|
|
||||
| Open P0 (2: PDF/X-4 validation against a certified checker, PostScript interpretation for EPS) | 8 h |
|
||||
| Open P1 (none partial) | 0 h |
|
||||
| Open P2 (4 partial: localization breadth, INDD pages (not readable clean-room), in-app media playback, warichu) | 14 h |
|
||||
| Open P2 (3 partial: localization breadth, INDD pages (not readable clean-room), in-app media playback) | 14 h |
|
||||
| Depth and pixel fidelity of every dialog, panel and menu against InDesign 2026 | 90 h |
|
||||
| Performance (incremental composition, GPU raster) and interchange hardening (IDML/PDF corpus) | 55 h |
|
||||
|
||||
|
||||
+243
-1
@@ -20,6 +20,7 @@ mod ruby;
|
||||
pub mod shape;
|
||||
pub mod table;
|
||||
pub mod vars;
|
||||
mod warichu;
|
||||
pub mod xref;
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -70,6 +71,17 @@ pub struct RunStyle {
|
||||
pub ruby: Option<String>,
|
||||
pub kenten: bool,
|
||||
pub kenten_character: String,
|
||||
/// Warichu: stack this run in smaller lines inside the parent em.
|
||||
pub warichu: bool,
|
||||
pub warichu_lines: u32,
|
||||
/// Percentage of the parent size.
|
||||
pub warichu_size: f64,
|
||||
/// Extra points between warichu baselines. 0 keeps baselines one small em apart. A negative
|
||||
/// value tightens that em, down to a shared baseline.
|
||||
pub warichu_line_spacing: f64,
|
||||
pub warichu_align: designcraft_doc::cjk::WarichuAlignment,
|
||||
pub warichu_chars_before: u32,
|
||||
pub warichu_chars_after: u32,
|
||||
}
|
||||
|
||||
/// An underline or strikethrough bar: its centre `offset` below the baseline (negative =
|
||||
@@ -963,6 +975,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
let forced_mid = brk == Some(story::FORCED_LINE_BREAK) && !last;
|
||||
let (mut placed, end_x, ratio) =
|
||||
layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, ends_para, forced_mid, f.left_page, &bidi_info);
|
||||
// Warichu runs before ruby so a reading is placed over the stacked note.
|
||||
let end_x = end_x + warichu::place(&styles_tab, &mut placed);
|
||||
ruby::annotate(db, &styles_tab, &mut placed, doc.settings.glyph_fallback);
|
||||
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);
|
||||
@@ -2568,10 +2582,132 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) {
|
||||
|
||||
// ---------- caret and hit testing (Type tool) ----------
|
||||
|
||||
/// Two stacked glyphs this far apart in `y` sit on different rows (warichu).
|
||||
const ROW_Y: f64 = 0.25;
|
||||
|
||||
/// (ascent, descent) of `g` in points, both positive.
|
||||
fn glyph_extents(g: &PlacedGlyph) -> (f64, f64) {
|
||||
let (asc, desc) = g.face.vertical_metrics();
|
||||
let sy = g.sy.abs();
|
||||
(asc * sy, desc * sy)
|
||||
}
|
||||
|
||||
fn x_span(g: &PlacedGlyph) -> (f64, f64) {
|
||||
(g.x.min(g.x + g.adv), g.x.max(g.x + g.adv))
|
||||
}
|
||||
|
||||
fn x_ranges_overlap(a: &PlacedGlyph, b: &PlacedGlyph) -> bool {
|
||||
let (a0, a1) = x_span(a);
|
||||
let (b0, b1) = x_span(b);
|
||||
a0 < b1 - 0.2 && b0 < a1 - 0.2
|
||||
}
|
||||
|
||||
fn line_glyphs(l: &Line) -> Vec<&PlacedGlyph> {
|
||||
l.glyphs.iter().filter(|g| g.len > 0).collect()
|
||||
}
|
||||
|
||||
/// `g` shares its x with another glyph on a different baseline (a stacked warichu row).
|
||||
fn on_stacked_row(glyphs: &[&PlacedGlyph], g: &PlacedGlyph) -> bool {
|
||||
glyphs.iter().any(|o| (o.y - g.y).abs() > ROW_Y && x_ranges_overlap(o, g))
|
||||
}
|
||||
|
||||
fn line_has_stacked_rows(l: &Line) -> bool {
|
||||
let glyphs = line_glyphs(l);
|
||||
glyphs.iter().any(|g| on_stacked_row(&glyphs, g))
|
||||
}
|
||||
|
||||
/// The glyph `caret_x` anchors `pos` to, if the caret is not the line end.
|
||||
fn caret_anchor(l: &Line, pos: usize) -> Option<&PlacedGlyph> {
|
||||
for g in l.glyphs.iter().filter(|g| g.len > 0) {
|
||||
if pos >= g.byte && pos < g.byte + g.len {
|
||||
return Some(g);
|
||||
}
|
||||
if g.byte >= pos {
|
||||
return Some(g);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Baseline an underline or strikethrough uses. Warichu follows the small line; everything else
|
||||
/// stays on the parent baseline (a baseline shift does not move the rule).
|
||||
pub fn rule_baseline(style: &RunStyle, line: &Line, g: &PlacedGlyph) -> f64 {
|
||||
if style.warichu { line.baseline + g.y } else { line.baseline }
|
||||
}
|
||||
|
||||
fn band_quad(x0: f64, x1: f64, top: f64, bot: f64) -> [Point; 4] {
|
||||
[Point::new(x0, top), Point::new(x1, top), Point::new(x1, bot), Point::new(x0, bot)]
|
||||
}
|
||||
|
||||
/// Highlight rectangles for bytes `s..e` on `l`. `past_end` extends the line-end caret when the
|
||||
/// selection continues onto the next line. Stacked rows (warichu) each get their own band.
|
||||
pub fn highlight_quads(l: &Line, s: usize, e: usize, past_end: bool) -> Vec<[Point; 4]> {
|
||||
let selected: Vec<&PlacedGlyph> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte >= s && g.byte < e).collect();
|
||||
let stacked = selected.iter().any(|g| on_stacked_row(&selected, g));
|
||||
if stacked {
|
||||
let mut items: Vec<(f64, f64, f64, f64, f64)> = selected
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let (asc, desc) = glyph_extents(g);
|
||||
let (x0, x1) = x_span(g);
|
||||
let y = l.baseline + g.y;
|
||||
(g.y, x0, x1, y - asc, y + desc)
|
||||
})
|
||||
.collect();
|
||||
items.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
|
||||
let mut quads = Vec::new();
|
||||
let mut cur: Option<(f64, f64, f64, f64, f64)> = None;
|
||||
for (y, x0, x1, top, bot) in items {
|
||||
match cur {
|
||||
Some(c) if (c.0 - y).abs() <= ROW_Y && x0 <= c.2 + 0.5 => {
|
||||
cur = Some((c.0, c.1, c.2.max(x1), c.3.min(top), c.4.max(bot)));
|
||||
}
|
||||
Some(c) => {
|
||||
quads.push(band_quad(c.1, c.2, c.3, c.4));
|
||||
cur = Some((y, x0, x1, top, bot));
|
||||
}
|
||||
None => cur = Some((y, x0, x1, top, bot)),
|
||||
}
|
||||
}
|
||||
if let Some(c) = cur {
|
||||
quads.push(band_quad(c.1, c.2, c.3, c.4));
|
||||
}
|
||||
if past_end {
|
||||
quads.push(band_quad(l.end_x, l.end_x + 3.0, l.baseline - l.ascent, l.baseline + l.descent));
|
||||
}
|
||||
return quads;
|
||||
}
|
||||
let top = l.baseline - l.ascent;
|
||||
let bot = l.baseline + l.descent;
|
||||
if l.glyphs.iter().any(|g| g.rtl) {
|
||||
let mut spans: Vec<(f64, f64)> = selected.iter().map(|g| (g.x, g.x + g.adv)).collect();
|
||||
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut merged: Vec<(f64, f64)> = Vec::new();
|
||||
for (a, b) in spans {
|
||||
match merged.last_mut() {
|
||||
Some(m) if a <= m.1 + 0.5 => m.1 = m.1.max(b),
|
||||
_ => merged.push((a, b)),
|
||||
}
|
||||
}
|
||||
return merged.into_iter().map(|(a, b)| band_quad(a, b, top, bot)).collect();
|
||||
}
|
||||
let x0 = caret_x(l, s);
|
||||
let x1 = if past_end { l.end_x.max(x0 + 3.0) } else { caret_x(l, e) };
|
||||
vec![band_quad(x0, x1, top, bot)]
|
||||
}
|
||||
|
||||
/// Caret geometry for story byte `pos`: (frame index, x, baseline, ascent, descent).
|
||||
/// A stacked warichu row reports that row's baseline and em, not the parent line's.
|
||||
pub fn caret(cs: &ComposedStory, pos: usize) -> Option<(usize, f64, f64, f64, f64)> {
|
||||
let (fi, l) = caret_line(cs, pos)?;
|
||||
Some((fi, caret_x(l, pos), l.baseline, l.ascent, l.descent))
|
||||
let x = caret_x(l, pos);
|
||||
let glyphs = line_glyphs(l);
|
||||
let metrics = caret_anchor(l, pos).filter(|g| on_stacked_row(&glyphs, g)).map(|g| {
|
||||
let (asc, desc) = glyph_extents(g);
|
||||
(l.baseline + g.y, asc, desc)
|
||||
});
|
||||
let (bl, asc, desc) = metrics.unwrap_or((l.baseline, l.ascent, l.descent));
|
||||
Some((fi, x, bl, asc, desc))
|
||||
}
|
||||
|
||||
/// The line the caret at `pos` is drawn on (and its frame index).
|
||||
@@ -2661,6 +2797,9 @@ pub fn hit(cs: &ComposedStory, fi: usize, p: Point) -> Option<usize> {
|
||||
// Bidi: the caret position drawn nearest the point.
|
||||
return caret_stops(l).into_iter().min_by(|a, b| (a.1 - p.x).abs().total_cmp(&(b.1 - p.x).abs())).map(|s| s.0);
|
||||
}
|
||||
if line_has_stacked_rows(l) {
|
||||
return Some(hit_stacked(l, p));
|
||||
}
|
||||
let mut best = l.range.start;
|
||||
let mut prev_mid = f64::NEG_INFINITY;
|
||||
for g in l.glyphs.iter().filter(|g| g.len > 0) {
|
||||
@@ -2687,6 +2826,109 @@ fn line_dist(l: &Line, y: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
fn glyph_contains(l: &Line, g: &PlacedGlyph, p: Point) -> bool {
|
||||
let (x0, x1) = x_span(g);
|
||||
if p.x < x0 - 0.01 || p.x > x1 + 0.01 {
|
||||
return false;
|
||||
}
|
||||
let (asc, desc) = glyph_extents(g);
|
||||
let y = l.baseline + g.y;
|
||||
p.y >= y - asc && p.y <= y + desc
|
||||
}
|
||||
|
||||
/// Midpoint walk over `glyphs` (visual order). Past the last glyph yields the byte after it, or
|
||||
/// the line end when that glyph closes the line.
|
||||
fn hit_sorted(l: &Line, mut glyphs: Vec<&PlacedGlyph>, p_x: f64) -> usize {
|
||||
glyphs.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
let last_byte = l.glyphs.iter().rev().find(|g| g.len > 0).map(|g| g.byte);
|
||||
let mut best = l.range.start;
|
||||
let mut prev_mid = f64::NEG_INFINITY;
|
||||
let mut saw_last = false;
|
||||
for g in &glyphs {
|
||||
if last_byte == Some(g.byte) {
|
||||
saw_last = true;
|
||||
}
|
||||
let mid = g.x + g.adv / 2.0;
|
||||
if p_x < mid && p_x >= prev_mid {
|
||||
return g.byte;
|
||||
}
|
||||
if mid > prev_mid {
|
||||
prev_mid = mid;
|
||||
}
|
||||
best = g.byte + g.len;
|
||||
}
|
||||
if glyphs.is_empty() {
|
||||
return if l.last_in_para { l.range.end } else { l.range.start };
|
||||
}
|
||||
if saw_last && l.last_in_para { l.range.end } else { best.min(l.range.end) }
|
||||
}
|
||||
|
||||
fn hit_stacked(l: &Line, p: Point) -> usize {
|
||||
let glyphs = line_glyphs(l);
|
||||
let stacked = |g: &PlacedGlyph| on_stacked_row(&glyphs, g);
|
||||
let mut containing: Vec<&PlacedGlyph> = glyphs.iter().copied().filter(|g| glyph_contains(l, g, p)).collect();
|
||||
containing.sort_by(|a, b| {
|
||||
let da = (l.baseline + a.y - p.y).abs();
|
||||
let db = (l.baseline + b.y - p.y).abs();
|
||||
da.total_cmp(&db).then(b.x.total_cmp(&a.x))
|
||||
});
|
||||
if let Some(g) = containing.first() {
|
||||
let y = g.y;
|
||||
let row: Vec<&PlacedGlyph> = if stacked(g) {
|
||||
glyphs.iter().copied().filter(|o| (o.y - y).abs() <= ROW_Y).collect()
|
||||
} else {
|
||||
glyphs.iter().copied().filter(|o| !stacked(o)).collect()
|
||||
};
|
||||
return hit_sorted(l, row, p.x);
|
||||
}
|
||||
// Between the rows, or in the line box beside them: the nearest row under this x, else the
|
||||
// glyphs that are not part of the stack (the text after the note).
|
||||
let mut best: Option<(f64, f64)> = None;
|
||||
for g in glyphs.iter().copied().filter(|g| stacked(g)) {
|
||||
let (x0, x1) = glyphs.iter().copied().filter(|o| (o.y - g.y).abs() <= ROW_Y).fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), o| {
|
||||
let (u, v) = x_span(o);
|
||||
(a.min(u), b.max(v))
|
||||
});
|
||||
if p.x < x0 - 1.0 || p.x > x1 + 1.0 {
|
||||
continue;
|
||||
}
|
||||
let dist = (l.baseline + g.y - p.y).abs();
|
||||
if best.is_none_or(|(d, _)| dist < d) {
|
||||
best = Some((dist, g.y));
|
||||
}
|
||||
}
|
||||
if let Some((_, y)) = best {
|
||||
let row = glyphs.iter().copied().filter(|o| (o.y - y).abs() <= ROW_Y).collect();
|
||||
return hit_sorted(l, row, p.x);
|
||||
}
|
||||
let rest = glyphs.iter().copied().filter(|g| !on_stacked_row(&glyphs, g)).collect();
|
||||
hit_sorted(l, rest, p.x)
|
||||
}
|
||||
|
||||
/// Baseline of the stacked row above (`up`) or below the caret at `(x, caret_y)` on `l`.
|
||||
/// `None` when the caret is not over a stack, or that side has no further row.
|
||||
pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option<f64> {
|
||||
let glyphs = line_glyphs(l);
|
||||
let mut rows: Vec<(f64, f64, f64)> = Vec::new();
|
||||
for g in glyphs.iter().copied().filter(|g| on_stacked_row(&glyphs, g)) {
|
||||
let y = l.baseline + g.y;
|
||||
if rows.iter().any(|r| (r.0 - y).abs() <= ROW_Y) {
|
||||
continue;
|
||||
}
|
||||
let (x0, x1) =
|
||||
glyphs.iter().copied().filter(|o| (l.baseline + o.y - y).abs() <= ROW_Y).fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), o| {
|
||||
let (u, v) = x_span(o);
|
||||
(a.min(u), b.max(v))
|
||||
});
|
||||
rows.push((y, x0, x1));
|
||||
}
|
||||
// The right edge belongs to the following character, so a caret parked there does not
|
||||
// count as still inside the note.
|
||||
let over = |r: &(f64, f64, f64)| x >= r.1 - 0.01 && x < r.2 - 0.01;
|
||||
let candidates = rows.iter().filter(|r| over(r)).filter(|r| if up { r.0 < caret_y - ROW_Y } else { r.0 > caret_y + ROW_Y });
|
||||
if up { candidates.map(|r| r.0).max_by(|a, b| a.total_cmp(b)) } else { candidates.map(|r| r.0).min_by(|a, b| a.total_cmp(b)) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
|
||||
@@ -162,6 +162,13 @@ impl StyleTable<'_> {
|
||||
ruby: (!p.ruby.is_empty()).then(|| p.ruby.clone()),
|
||||
kenten: p.kenten,
|
||||
kenten_character: p.kenten_character.clone(),
|
||||
warichu: p.warichu,
|
||||
warichu_lines: p.warichu_lines,
|
||||
warichu_size: p.warichu_size,
|
||||
warichu_line_spacing: p.warichu_line_spacing,
|
||||
warichu_align: p.warichu_alignment,
|
||||
warichu_chars_before: p.warichu_chars_before_break,
|
||||
warichu_chars_after: p.warichu_chars_after_break,
|
||||
};
|
||||
if let Some(i) = self.styles.iter().rposition(|s| *s == rs) {
|
||||
return i as u32;
|
||||
|
||||
@@ -1065,6 +1065,192 @@ fn ruby_and_kenten_sit_over_their_text() {
|
||||
assert!((dot.x + dot.adv / 2.0 - (de.x + de.adv / 2.0)).abs() < 1.0 && dot.y < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warichu_stacks_the_run_inside_the_line_and_closes_up() {
|
||||
let text = "ABCDEFGHZ";
|
||||
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 500.0, 200.0), lid, text, ParaFormat::default()).unwrap();
|
||||
let plain = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].glyphs.clone();
|
||||
let z0 = plain.iter().find(|g| g.byte == 8 && g.len > 0).unwrap().x;
|
||||
let sx0 = plain.iter().find(|g| g.byte == 0 && g.len > 0).unwrap().sx;
|
||||
d.story_mut(sid).unwrap().format_chars(0..8, |f| {
|
||||
f.over.warichu = Some(true);
|
||||
f.over.warichu_alignment = Some(designcraft_doc::cjk::WarichuAlignment::Left);
|
||||
});
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let l = &cs.frames[0].lines[0];
|
||||
let run: Vec<_> = l.glyphs.iter().filter(|g| g.byte < 8 && g.len > 0).collect();
|
||||
assert_eq!(run.len(), 8, "the note keeps one glyph per letter");
|
||||
let size = cs.styles[run[0].style as usize].size;
|
||||
assert!((run[0].sx - sx0 * 0.5).abs() < 1e-6, "half the parent size");
|
||||
let y_of = |slice: &[&PlacedGlyph]| slice.iter().map(|g| g.y).sum::<f64>() / slice.len() as f64;
|
||||
let (y_top, y_bot) = (y_of(&run[..4]), y_of(&run[4..]));
|
||||
assert!((y_bot - y_top - size * 0.5).abs() < 0.05 * size, "two lines one small em apart: {y_top} {y_bot} {size}");
|
||||
assert!((run[0].x - run[4].x).abs() < 1e-6, "left alignment shares the start");
|
||||
let z = l.glyphs.iter().find(|g| g.byte == 8 && g.len > 0).unwrap();
|
||||
let right = run.iter().map(|g| g.x + g.adv).fold(f64::MIN, f64::max);
|
||||
assert!((z.x - right).abs() < 1e-3, "the next character follows the note: {} {right}", z.x);
|
||||
assert!(z.x < z0 - 1.0, "the note is narrower than the full-size run: {} {z0}", z.x);
|
||||
assert!((l.end_x - (z.x + z.adv)).abs() < 1e-3, "the line-end caret follows the close-up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warichu_break_minimum_keeps_a_short_run_on_one_line() {
|
||||
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 500.0, 200.0), lid, "ABCD", ParaFormat::default()).unwrap();
|
||||
d.story_mut(sid).unwrap().format_chars(0..4, |f| {
|
||||
f.over.warichu = Some(true);
|
||||
f.over.warichu_lines = Some(2);
|
||||
f.over.warichu_chars_before_break = Some(3);
|
||||
f.over.warichu_chars_after_break = Some(3);
|
||||
});
|
||||
let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0];
|
||||
let run: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte < 4).collect();
|
||||
assert_eq!(run.len(), 4);
|
||||
let y0 = run[0].y;
|
||||
assert!(run.iter().all(|g| (g.y - y0).abs() < 1e-6), "not enough characters to break");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warichu_break_minimums_apply_to_the_first_and_last_line() {
|
||||
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 500.0, 200.0), lid, "ABCDEFGH", ParaFormat::default()).unwrap();
|
||||
d.story_mut(sid).unwrap().format_chars(0..8, |f| {
|
||||
f.over.warichu = Some(true);
|
||||
f.over.warichu_lines = Some(2);
|
||||
f.over.warichu_alignment = Some(designcraft_doc::cjk::WarichuAlignment::Left);
|
||||
f.over.warichu_chars_before_break = Some(1);
|
||||
f.over.warichu_chars_after_break = Some(5);
|
||||
});
|
||||
let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0];
|
||||
let run: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte < 8).collect();
|
||||
let y0 = run[0].y;
|
||||
let first = run.iter().filter(|g| (g.y - y0).abs() < 1e-6).count();
|
||||
let second = run.len() - first;
|
||||
assert_eq!(first + second, run.len());
|
||||
assert!(first >= 1 && second >= 5, "before 1 and after 5 on eight letters: {first} {second}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warichu_rows_balance_by_width() {
|
||||
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 500.0, 200.0), lid, "IIIIWWWW", ParaFormat::default()).unwrap();
|
||||
d.story_mut(sid).unwrap().format_chars(0..8, |f| {
|
||||
f.over.warichu = Some(true);
|
||||
f.over.warichu_lines = Some(2);
|
||||
f.over.warichu_alignment = Some(designcraft_doc::cjk::WarichuAlignment::Left);
|
||||
});
|
||||
let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0];
|
||||
let run: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte < 8).collect();
|
||||
let y0 = run[0].y;
|
||||
let width = |pred: bool| run.iter().filter(|g| ((g.y - y0).abs() < 1e-6) == pred).map(|g| g.adv).sum::<f64>();
|
||||
let (top, bot) = (width(true), width(false));
|
||||
let widest = run.iter().map(|g| g.adv).fold(0.0_f64, f64::max);
|
||||
assert!((top - bot).abs() <= widest + 0.05, "rows differ by at most one glyph: {top} {bot}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warichu_negative_spacing_tightens_the_rows() {
|
||||
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 500.0, 200.0), lid, "ABCDEFGH", ParaFormat::default()).unwrap();
|
||||
d.story_mut(sid).unwrap().format_chars(0..8, |f| {
|
||||
f.over.warichu = Some(true);
|
||||
f.over.warichu_alignment = Some(designcraft_doc::cjk::WarichuAlignment::Left);
|
||||
f.over.warichu_line_spacing = Some(-1.0);
|
||||
});
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let l = &cs.frames[0].lines[0];
|
||||
let run: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte < 8).collect();
|
||||
let size = cs.styles[run[0].style as usize].size;
|
||||
let gap = run[4].y - run[0].y;
|
||||
assert!((gap - (size * 0.5 - 1.0)).abs() < 0.05, "one point tighter than a small em: {gap} {size}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warichu_tab_leaders_stay_in_the_gap() {
|
||||
let frame = |warichu: bool| {
|
||||
let mut doc = Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
let lid = doc.default_layer();
|
||||
let pf = ParaFormat {
|
||||
para: ParaAttrs {
|
||||
tabs: Some(vec![designcraft_doc::TabStop {
|
||||
position: 200.0,
|
||||
align: designcraft_doc::TabAlign::Right,
|
||||
leader: ".".into(),
|
||||
align_on: String::new(),
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 100.0), lid, "Intro\tABCDEFGH", pf).unwrap();
|
||||
if warichu {
|
||||
let at = "Intro\t".len();
|
||||
doc.story_mut(sid).unwrap().format_chars(at..at + 8, |f| {
|
||||
f.over.warichu = Some(true);
|
||||
f.over.warichu_alignment = Some(designcraft_doc::cjk::WarichuAlignment::Left);
|
||||
});
|
||||
}
|
||||
let cs = compose_story(&doc, sid, &ComposeOptions::default());
|
||||
let line = cs.frames[0].lines[0].clone();
|
||||
let dots: Vec<f64> = line.glyphs.iter().filter(|g| g.len == 0 && g.visible).map(|g| g.x).collect();
|
||||
(dots, line)
|
||||
};
|
||||
let (plain, _) = frame(false);
|
||||
let (noted, line) = frame(true);
|
||||
assert!(plain.len() > 5 && plain.len() == noted.len(), "the leader is not rebuilt or dropped: {} {}", plain.len(), noted.len());
|
||||
for (a, b) in plain.iter().zip(¬ed) {
|
||||
assert!((a - b).abs() < 1e-6, "a leader before the note stays put: {a} {b}");
|
||||
}
|
||||
let note_x = line.glyphs.iter().filter(|g| g.len > 0 && g.byte >= "Intro\t".len()).map(|g| g.x).fold(f64::INFINITY, f64::min);
|
||||
assert!(noted.iter().all(|x| *x < note_x), "leaders stay ahead of the note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warichu_hit_caret_and_selection_follow_each_row() {
|
||||
let text = "ABCDEFGHZ";
|
||||
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 500.0, 200.0), lid, text, ParaFormat::default()).unwrap();
|
||||
d.story_mut(sid).unwrap().format_chars(0..8, |f| {
|
||||
f.over.warichu = Some(true);
|
||||
f.over.warichu_alignment = Some(designcraft_doc::cjk::WarichuAlignment::Left);
|
||||
});
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let l = &cs.frames[0].lines[0];
|
||||
let at = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte && g.len > 0).unwrap();
|
||||
let top = at(0);
|
||||
let bot = at(4);
|
||||
let z = at(8);
|
||||
let (_, _, y0, asc, _) = caret(&cs, 0).unwrap();
|
||||
let (_, _, y4, _, _) = caret(&cs, 4).unwrap();
|
||||
let (_, x_end, y_end, _, _) = caret(&cs, text.len()).unwrap();
|
||||
assert!((y0 - (l.baseline + top.y)).abs() < 1e-6 && (y4 - (l.baseline + bot.y)).abs() < 1e-6, "caret sits on the row");
|
||||
assert!(asc < l.ascent * 0.8, "the row caret is shorter than the parent line");
|
||||
assert!((y_end - l.baseline).abs() < 1e-6 && (x_end - l.end_x).abs() < 1e-3, "the line end stays on the parent baseline");
|
||||
let click = |g: &PlacedGlyph| hit(&cs, 0, designcraft_geom::Point::new(g.x + g.adv * 0.25, l.baseline + g.y)).unwrap();
|
||||
assert_eq!(click(top), 0);
|
||||
assert_eq!(click(bot), 4);
|
||||
assert_eq!(hit(&cs, 0, designcraft_geom::Point::new(z.x + z.adv * 0.25, l.baseline)).unwrap(), 8);
|
||||
let up = adjacent_row(l, bot.x + bot.adv * 0.25, l.baseline + bot.y, true).unwrap();
|
||||
assert!(hit(&cs, 0, designcraft_geom::Point::new(bot.x + bot.adv * 0.25, up)).unwrap() < 4);
|
||||
assert!(adjacent_row(l, top.x + 0.1, l.baseline + top.y, true).is_none(), "the top row does not move up inside the note");
|
||||
assert!(adjacent_row(l, z.x + 0.1, l.baseline, false).is_none(), "the character after the note is not inside a row");
|
||||
let quads = highlight_quads(l, 0, 8, false);
|
||||
assert_eq!(quads.len(), 2, "one band per row");
|
||||
let mid_y = |q: &[designcraft_geom::Point; 4]| (q[0].y + q[2].y) / 2.0;
|
||||
assert!(mid_y(&quads[0]) < l.baseline && mid_y(&quads[1]) > l.baseline, "the bands sit on either side of the parent baseline");
|
||||
let style = &cs.styles[top.style as usize];
|
||||
assert!((rule_baseline(style, l, top) - (l.baseline + top.y)).abs() < 1e-9);
|
||||
let z_style = &cs.styles[z.style as usize];
|
||||
assert!(!z_style.warichu && (rule_baseline(z_style, l, z) - l.baseline).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tate_chu_yoko_sets_digits_across_one_em() {
|
||||
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Warichu (割り注): an inline note set in two or more smaller lines inside one parent em.
|
||||
//!
|
||||
//! Placement happens after the line is broken, in the composer's along/across space (`x` runs
|
||||
//! along the line). The run gets shorter and the rest of the line closes up. Characters that did
|
||||
//! not fit the line stay on the next line. Right-to-left and tate-chu-yoko runs are left alone.
|
||||
|
||||
use designcraft_doc::cjk::WarichuAlignment;
|
||||
|
||||
use crate::{PlacedGlyph, RunStyle};
|
||||
|
||||
pub(crate) fn place(styles: &[RunStyle], line: &mut [PlacedGlyph]) -> f64 {
|
||||
if !line.iter().any(|g| is_warichu(styles, g)) {
|
||||
return 0.0;
|
||||
}
|
||||
let mut delta = 0.0;
|
||||
let mut i = 0;
|
||||
while i < line.len() {
|
||||
if !is_warichu(styles, &line[i]) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(key) = key(styles, &line[i]) else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let mut j = i + 1;
|
||||
while j < line.len() {
|
||||
let g = &line[j];
|
||||
if g.len == 0 {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
if is_warichu(styles, g) && key_of(styles, g) == Some(key) {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
let members: Vec<usize> = (i..j).filter(|&k| is_warichu(styles, &line[k])).collect();
|
||||
if members.is_empty() || members.iter().any(|&k| line[k].rtl || line[k].tcy.is_some()) {
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
delta += layout(styles, line, &members);
|
||||
i = j;
|
||||
}
|
||||
delta
|
||||
}
|
||||
|
||||
fn style<'a>(styles: &'a [RunStyle], g: &PlacedGlyph) -> Option<&'a RunStyle> {
|
||||
styles.get(g.style as usize)
|
||||
}
|
||||
|
||||
fn is_warichu(styles: &[RunStyle], g: &PlacedGlyph) -> bool {
|
||||
g.len > 0 && g.visible && style(styles, g).is_some_and(|s| s.warichu)
|
||||
}
|
||||
|
||||
type Key = (u32, u64, u64, WarichuAlignment, u32, u32);
|
||||
|
||||
fn key(styles: &[RunStyle], g: &PlacedGlyph) -> Option<Key> {
|
||||
let s = style(styles, g)?;
|
||||
Some((
|
||||
s.warichu_lines,
|
||||
s.warichu_size.to_bits(),
|
||||
s.warichu_line_spacing.to_bits(),
|
||||
s.warichu_align,
|
||||
s.warichu_chars_before,
|
||||
s.warichu_chars_after,
|
||||
))
|
||||
}
|
||||
|
||||
fn key_of(styles: &[RunStyle], g: &PlacedGlyph) -> Option<Key> {
|
||||
is_warichu(styles, g).then(|| key(styles, g)).flatten()
|
||||
}
|
||||
|
||||
/// How many small lines `len` glyphs can fill. The first line keeps at least `before` glyphs and
|
||||
/// the last at least `after`; every other line keeps one. Fewer lines than `want` when the run is
|
||||
/// too short for that break.
|
||||
fn line_count(len: usize, want: usize, before: usize, after: usize) -> usize {
|
||||
if len == 0 {
|
||||
return 1;
|
||||
}
|
||||
let mut n = want.clamp(1, 16).min(len);
|
||||
let before = before.max(1);
|
||||
let after = after.max(1);
|
||||
while n >= 2 {
|
||||
if before + after + n - 2 <= len {
|
||||
return n;
|
||||
}
|
||||
n -= 1;
|
||||
}
|
||||
1
|
||||
}
|
||||
|
||||
/// Glyph counts for `n` lines. Minimises the widest line, and the first and last lines keep their
|
||||
/// break minimums. One line when no split satisfies them.
|
||||
fn split_even(advs: &[f64], n: usize, before: usize, after: usize) -> Vec<usize> {
|
||||
let m = advs.len();
|
||||
if n <= 1 || m == 0 {
|
||||
return vec![m];
|
||||
}
|
||||
let before = before.clamp(1, m);
|
||||
let after = after.clamp(1, m);
|
||||
let mut pref = vec![0.0_f64; m + 1];
|
||||
for (i, a) in advs.iter().enumerate() {
|
||||
pref[i + 1] = pref[i] + *a;
|
||||
}
|
||||
let mut best = vec![vec![f64::INFINITY; m + 1]; n + 1];
|
||||
let mut take = vec![vec![0_usize; m + 1]; n + 1];
|
||||
for s in 0..m {
|
||||
if m - s >= after {
|
||||
best[1][s] = pref[m] - pref[s];
|
||||
take[1][s] = m - s;
|
||||
}
|
||||
}
|
||||
for lines in 2..=n {
|
||||
for s in 0..m {
|
||||
let min_t = if lines == n { before } else { 1 };
|
||||
let min_leave = after + lines - 2;
|
||||
if s + min_t + min_leave > m {
|
||||
continue;
|
||||
}
|
||||
let max_t = m - s - min_leave;
|
||||
let mut local = f64::INFINITY;
|
||||
let mut best_t = min_t;
|
||||
for t in min_t..=max_t {
|
||||
let rest = best[lines - 1][s + t];
|
||||
if !rest.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let mx = (pref[s + t] - pref[s]).max(rest);
|
||||
if mx < local {
|
||||
local = mx;
|
||||
best_t = t;
|
||||
}
|
||||
}
|
||||
best[lines][s] = local;
|
||||
take[lines][s] = best_t;
|
||||
}
|
||||
}
|
||||
if !best[n][0].is_finite() {
|
||||
return vec![m];
|
||||
}
|
||||
let mut counts = Vec::with_capacity(n);
|
||||
let mut s = 0_usize;
|
||||
for lines in (1..=n).rev() {
|
||||
let t = take[lines][s];
|
||||
if t == 0 || s + t > m {
|
||||
return vec![m];
|
||||
}
|
||||
counts.push(t);
|
||||
s += t;
|
||||
}
|
||||
if s != m { vec![m] } else { counts }
|
||||
}
|
||||
|
||||
fn restore(line: &mut [PlacedGlyph], members: &[usize], saved: &[(f64, f64, f64, f64)]) {
|
||||
for (i, &k) in members.iter().enumerate() {
|
||||
let Some((y, adv, sx, sy)) = saved.get(i).copied() else { continue };
|
||||
let Some(g) = line.get_mut(k) else { continue };
|
||||
g.y = y;
|
||||
g.adv = adv;
|
||||
g.sx = sx;
|
||||
g.sy = sy;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale `members` and stack them in `lines`. Returns how far the rest of the line moves.
|
||||
fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f64 {
|
||||
let Some(st) = members.first().and_then(|&k| style(styles, &line[k])) else {
|
||||
return 0.0;
|
||||
};
|
||||
let before = st.warichu_chars_before as usize;
|
||||
let after = st.warichu_chars_after as usize;
|
||||
let n = line_count(members.len(), st.warichu_lines as usize, before, after);
|
||||
let scale = (st.warichu_size / 100.0).clamp(0.05, 4.0);
|
||||
let old_x0 = members.iter().map(|&k| line[k].x).fold(f64::INFINITY, f64::min);
|
||||
let old_right = members.iter().map(|&k| line[k].x + line[k].adv).fold(f64::NEG_INFINITY, f64::max);
|
||||
let old_span = old_right - old_x0;
|
||||
if !old_span.is_finite() || old_span <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let saved: Vec<(f64, f64, f64, f64)> = members
|
||||
.iter()
|
||||
.map(|&k| {
|
||||
let g = &line[k];
|
||||
(g.y, g.adv, g.sx, g.sy)
|
||||
})
|
||||
.collect();
|
||||
let old_xs: Vec<f64> = members.iter().map(|&k| line[k].x).collect();
|
||||
for &k in members {
|
||||
line[k].sx *= scale;
|
||||
line[k].sy *= scale;
|
||||
line[k].adv *= scale;
|
||||
line[k].y *= scale;
|
||||
}
|
||||
let scaled: Vec<f64> = members.iter().map(|&k| line[k].adv).collect();
|
||||
if scaled.iter().any(|a| !a.is_finite()) {
|
||||
restore(line, members, &saved);
|
||||
return 0.0;
|
||||
}
|
||||
let counts = if n <= 1 { vec![members.len()] } else { split_even(&scaled, n, before.max(1), after.max(1)) };
|
||||
let mut widths = Vec::with_capacity(counts.len());
|
||||
let mut off = 0_usize;
|
||||
for &c in &counts {
|
||||
if off + c > members.len() {
|
||||
restore(line, members, &saved);
|
||||
return 0.0;
|
||||
}
|
||||
let w: f64 = members[off..off + c].iter().map(|&k| line[k].adv).sum();
|
||||
widths.push(w);
|
||||
off += c;
|
||||
}
|
||||
let block = widths.iter().copied().fold(0.0_f64, f64::max);
|
||||
if !block.is_finite() || off != members.len() {
|
||||
restore(line, members, &saved);
|
||||
return 0.0;
|
||||
}
|
||||
let rows = counts.len().max(1);
|
||||
let small = st.size * scale;
|
||||
// 0 is the default (one small em). Any other value, including a negative one, is added to
|
||||
// that em and then clamped so the rows cannot swap order.
|
||||
let gap = if st.warichu_line_spacing == 0.0 { small } else { (small + st.warichu_line_spacing).max(0.0) };
|
||||
let mut off = 0_usize;
|
||||
for (li, &c) in counts.iter().enumerate() {
|
||||
let w = widths[li];
|
||||
let (start_x, extra) = match st.warichu_align {
|
||||
WarichuAlignment::Left => (0.0, 0.0),
|
||||
WarichuAlignment::Right => ((block - w).max(0.0), 0.0),
|
||||
WarichuAlignment::Justify if c > 1 && block > w => (0.0, (block - w) / (c - 1) as f64),
|
||||
WarichuAlignment::Auto | WarichuAlignment::Center | WarichuAlignment::Justify => ((block - w) / 2.0, 0.0),
|
||||
};
|
||||
let y_off = (li as f64 - (rows as f64 - 1.0) / 2.0) * gap;
|
||||
let mut x = old_x0 + start_x;
|
||||
for &k in &members[off..off + c] {
|
||||
line[k].x = x;
|
||||
line[k].y += y_off;
|
||||
x += line[k].adv + extra;
|
||||
}
|
||||
off += c;
|
||||
}
|
||||
let delta = block - old_span;
|
||||
let mut placed: Vec<(f64, f64)> = old_xs.iter().enumerate().map(|(i, ox)| (*ox, line[members[i]].x)).collect();
|
||||
placed.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut member_at = vec![false; line.len()];
|
||||
for &k in members {
|
||||
if let Some(slot) = member_at.get_mut(k) {
|
||||
*slot = true;
|
||||
}
|
||||
}
|
||||
for (k, g) in line.iter_mut().enumerate() {
|
||||
if member_at.get(k).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
if g.x >= old_right - 1e-6 {
|
||||
g.x += delta;
|
||||
} else if g.x >= old_x0 - 1e-6
|
||||
&& let Some(anchor) = placed.iter().rev().find(|p| p.0 <= g.x + 1e-6)
|
||||
{
|
||||
// A mark drawn with the run (a tab leader is not: it sits before old_x0) keeps its
|
||||
// offset from the glyph it was attached to, in the scaled line.
|
||||
g.x = anchor.1 + (g.x - anchor.0) * scale;
|
||||
}
|
||||
}
|
||||
delta
|
||||
}
|
||||
@@ -692,6 +692,18 @@ attr_set! {
|
||||
ruby: String = String::new(),
|
||||
/// Kenten: an emphasis dot above each character.
|
||||
kenten: bool = false,
|
||||
/// Warichu: the run is set in smaller lines stacked inside one line height.
|
||||
warichu: bool = false,
|
||||
/// How many warichu lines (InDesign's minimum is 2).
|
||||
warichu_lines: u32 = 2,
|
||||
/// Warichu size as a percentage of the parent size.
|
||||
warichu_size: f64 = 50.0,
|
||||
/// Extra points between warichu baselines. 0 is automatic (one small em).
|
||||
warichu_line_spacing: f64 = 0.0,
|
||||
warichu_alignment: crate::cjk::WarichuAlignment = crate::cjk::WarichuAlignment::Auto,
|
||||
/// Minimum characters on a warichu line before a break, and on the line after it.
|
||||
warichu_chars_before_break: u32 = 1,
|
||||
warichu_chars_after_break: u32 = 1,
|
||||
/// Digits (World-Ready): how 0–9 are drawn.
|
||||
digits: Digits = Digits::Default,
|
||||
character_direction: crate::arabic::CharacterDirection = crate::arabic::CharacterDirection::Default,
|
||||
|
||||
@@ -87,6 +87,19 @@ pub enum CharacterAlignment {
|
||||
IcfBottom,
|
||||
}
|
||||
|
||||
/// How the short lines of a warichu run sit inside the run's block.
|
||||
/// `Auto` centres them. `Justify` spreads a short line out to the block width.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WarichuAlignment {
|
||||
#[default]
|
||||
Auto,
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
Justify,
|
||||
}
|
||||
|
||||
impl Kinsoku {
|
||||
/// Clean-room Unicode rules for the named CJK sets. Explicit document tables
|
||||
/// are imported separately and always take precedence over these defaults.
|
||||
|
||||
@@ -206,6 +206,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
format_chars(s, &json!({"kenten": on}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"type.warichu",
|
||||
"Warichu",
|
||||
[],
|
||||
None,
|
||||
"{on?: bool, lines?: int, size?: number, lineSpacing?: number, align?: auto|left|center|right|justify, charsBeforeBreak?: int, charsAfterBreak?: int} — set the selection as an inline note in smaller lines inside one line height (toggles by default)",
|
||||
has_text_or_frames,
|
||||
warichu_cmd
|
||||
),
|
||||
cmd!(
|
||||
"type.storyDirection",
|
||||
"Story Direction",
|
||||
@@ -714,9 +723,18 @@ fn line_of(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> {
|
||||
}
|
||||
|
||||
fn vertical(cs: &compose::ComposedStory, pos: usize, up: bool) -> Option<usize> {
|
||||
let (fi, x, baseline, _, _) = compose::caret(cs, pos)?;
|
||||
let (fi, x, caret_y, _, _) = compose::caret(cs, pos)?;
|
||||
let lines: Vec<(usize, &compose::Line)> = cs.frames.iter().enumerate().flat_map(|(i, f)| f.lines.iter().map(move |l| (i, l))).collect();
|
||||
let cur = lines.iter().position(|(i, l)| *i == fi && (l.baseline - baseline).abs() < 0.01 && pos >= l.range.start && pos <= l.range.end)?;
|
||||
// Match the line by the bytes it owns. A warichu caret sits on a small-line baseline, so it
|
||||
// does not equal the parent line's baseline.
|
||||
let cur = lines.iter().position(|(i, l)| *i == fi && pos >= l.range.start && pos <= l.range.end && (pos < l.range.end || l.last_in_para))?;
|
||||
let (_, l) = lines.get(cur)?;
|
||||
if let Some(y) = compose::adjacent_row(l, x, caret_y, up)
|
||||
&& let Some(p) = compose::hit(cs, fi, Point::new(x, y))
|
||||
&& p != pos
|
||||
{
|
||||
return Some(p);
|
||||
}
|
||||
let target = if up { cur.checked_sub(1)? } else { cur + 1 };
|
||||
let (tf, tl) = lines.get(target)?;
|
||||
compose::hit(cs, *tf, Point::new(x, tl.baseline - 1.0))
|
||||
@@ -764,6 +782,43 @@ pub(crate) fn format_targets(s: &Session) -> Vec<Target> {
|
||||
v
|
||||
}
|
||||
|
||||
fn warichu_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let has_opts = ["lines", "size", "lineSpacing", "align", "charsBeforeBreak", "charsAfterBreak"].iter().any(|k| p.get(k).is_some());
|
||||
let on = match p.get("on").and_then(Value::as_bool) {
|
||||
Some(v) => v,
|
||||
None if has_opts => true,
|
||||
None => !format_targets(s).first().is_some_and(|t| {
|
||||
s.doc()
|
||||
.ok()
|
||||
.and_then(|d| d.doc.text_story(t.story, t.cell))
|
||||
.is_some_and(|st| st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over.warichu == Some(true))
|
||||
}),
|
||||
};
|
||||
let mut body = serde_json::Map::new();
|
||||
body.insert("warichu".into(), json!(on));
|
||||
if on {
|
||||
if let Some(v) = p.get("lines").and_then(Value::as_u64) {
|
||||
body.insert("warichuLines".into(), json!(v.clamp(2, 16)));
|
||||
}
|
||||
if let Some(v) = p.get("size").and_then(Value::as_f64).filter(|v| v.is_finite()) {
|
||||
body.insert("warichuSize".into(), json!(v.clamp(1.0, 1000.0)));
|
||||
}
|
||||
if let Some(v) = p.get("lineSpacing").and_then(Value::as_f64).filter(|v| v.is_finite()) {
|
||||
body.insert("warichuLineSpacing".into(), json!(v.clamp(-10_000.0, 10_000.0)));
|
||||
}
|
||||
if let Some(v) = p.get("align").and_then(Value::as_str) {
|
||||
body.insert("warichuAlignment".into(), json!(v));
|
||||
}
|
||||
if let Some(v) = p.get("charsBeforeBreak").and_then(Value::as_u64) {
|
||||
body.insert("warichuCharsBeforeBreak".into(), json!(v.min(100)));
|
||||
}
|
||||
if let Some(v) = p.get("charsAfterBreak").and_then(Value::as_u64) {
|
||||
body.insert("warichuCharsAfterBreak".into(), json!(v.min(100)));
|
||||
}
|
||||
}
|
||||
format_chars(s, &Value::Object(body))
|
||||
}
|
||||
|
||||
pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
|
||||
let mut a = CharAttrs::default();
|
||||
if let Some(o) = attrs.as_object() {
|
||||
@@ -1564,6 +1619,53 @@ mod tcy_tests {
|
||||
assert_eq!(f(&s).kenten, Some(false));
|
||||
assert_eq!(f(&s).ruby.as_deref(), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warichu_toggles_on_the_selection_and_undoes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "注釈です"})).unwrap();
|
||||
let sid = r["story"].as_u64().unwrap();
|
||||
let n = "注釈".len();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": n})).unwrap();
|
||||
s.execute("type.warichu", &json!({"lines": 2, "size": 40, "align": "left", "lineSpacing": 1, "charsBeforeBreak": 2, "charsAfterBreak": 2}))
|
||||
.unwrap();
|
||||
let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone();
|
||||
let a = f(&s);
|
||||
assert_eq!(a.warichu, Some(true));
|
||||
assert_eq!(a.warichu_lines, Some(2));
|
||||
assert_eq!(a.warichu_size, Some(40.0));
|
||||
assert_eq!(a.warichu_line_spacing, Some(1.0));
|
||||
assert_eq!(a.warichu_alignment, Some(designcraft_doc::cjk::WarichuAlignment::Left));
|
||||
assert_eq!(a.warichu_chars_before_break, Some(2));
|
||||
assert_eq!(a.warichu_chars_after_break, Some(2));
|
||||
// The characters after the selection are not part of the note.
|
||||
assert_eq!(s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(n + 1).over.warichu, None);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(f(&s).warichu, None);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(f(&s).warichu, Some(true));
|
||||
s.execute("type.warichu", &json!({})).unwrap();
|
||||
assert_eq!(f(&s).warichu, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warichu_up_and_down_move_between_the_rows() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "ABCDEFGHZ"})).unwrap();
|
||||
let sid = r["story"].as_u64().unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 8})).unwrap();
|
||||
s.execute("type.warichu", &json!({"lines": 2, "align": "left"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
|
||||
let down = s.execute("text.move", &json!({"dir": "down"})).unwrap()["pos"].as_u64().unwrap() as usize;
|
||||
assert!((4..8).contains(&down), "down enters the second row: {down}");
|
||||
let up = s.execute("text.move", &json!({"dir": "up"})).unwrap()["pos"].as_u64().unwrap() as usize;
|
||||
assert!(up < 4, "up returns to the first row: {up}");
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 8, "focus": 8})).unwrap();
|
||||
let stayed = s.execute("text.move", &json!({"dir": "down"})).unwrap()["pos"].as_u64().unwrap() as usize;
|
||||
assert_eq!(stayed, 8, "down from the character after the note does not fall back into a row");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -40,6 +40,29 @@ pub(crate) fn alignment_out(v: A) -> &'static str {
|
||||
A::IcfBottom => "AlignIcfBottom",
|
||||
}
|
||||
}
|
||||
pub(crate) fn warichu_align_in(v: &str) -> Option<designcraft_doc::cjk::WarichuAlignment> {
|
||||
use designcraft_doc::cjk::WarichuAlignment as A;
|
||||
Some(match v {
|
||||
"Auto" => A::Auto,
|
||||
"Left" | "LeftAlign" => A::Left,
|
||||
"Center" | "CenterAlign" => A::Center,
|
||||
"Right" | "RightAlign" => A::Right,
|
||||
"FullJustify" | "Justify" | "LeftJustify" | "CenterJustify" | "RightJustify" => A::Justify,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn warichu_align_out(v: designcraft_doc::cjk::WarichuAlignment) -> &'static str {
|
||||
use designcraft_doc::cjk::WarichuAlignment as A;
|
||||
match v {
|
||||
A::Auto => "Auto",
|
||||
A::Left => "Left",
|
||||
A::Center => "Center",
|
||||
A::Right => "Right",
|
||||
A::Justify => "FullJustify",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn kenten_character(v: &str) -> Option<&'static str> {
|
||||
Some(match v {
|
||||
"None" => "",
|
||||
|
||||
@@ -1165,6 +1165,27 @@ impl<'a> Ex<'a> {
|
||||
if let Some(v) = a.tate_chu_yoko {
|
||||
el.set("Tatechuyoko", bool_s(v));
|
||||
}
|
||||
if let Some(v) = a.warichu {
|
||||
el.set("Warichu", bool_s(v));
|
||||
}
|
||||
if let Some(v) = a.warichu_lines {
|
||||
el.set("WarichuLines", v);
|
||||
}
|
||||
if let Some(v) = a.warichu_size {
|
||||
el.set("WarichuSize", num(v));
|
||||
}
|
||||
if let Some(v) = a.warichu_line_spacing {
|
||||
el.set("WarichuLineSpacing", num(v));
|
||||
}
|
||||
if let Some(v) = a.warichu_alignment {
|
||||
el.set("WarichuAlignment", crate::cjk::warichu_align_out(v));
|
||||
}
|
||||
if let Some(v) = a.warichu_chars_before_break {
|
||||
el.set("WarichuCharsBeforeBreak", v);
|
||||
}
|
||||
if let Some(v) = a.warichu_chars_after_break {
|
||||
el.set("WarichuCharsAfterBreak", v);
|
||||
}
|
||||
if let Some(list) = &a.conditions {
|
||||
el.set("AppliedConditions", list.iter().map(|c| format!("Condition/{}", escape_id(c))).collect::<Vec<_>>().join(" "));
|
||||
}
|
||||
|
||||
@@ -1152,6 +1152,13 @@ impl<'r> Importer<'r> {
|
||||
a.tate_chu_yoko = e.boolean("Tatechuyoko");
|
||||
a.tate_chu_yoko_x_offset = e.num("TatechuyokoXOffset");
|
||||
a.tate_chu_yoko_y_offset = e.num("TatechuyokoYOffset");
|
||||
a.warichu = e.boolean("Warichu");
|
||||
a.warichu_lines = e.num("WarichuLines").map(|v| v.max(2.0) as u32);
|
||||
a.warichu_size = e.num("WarichuSize");
|
||||
a.warichu_line_spacing = e.num("WarichuLineSpacing");
|
||||
a.warichu_alignment = e.prop("WarichuAlignment").as_deref().and_then(crate::cjk::warichu_align_in);
|
||||
a.warichu_chars_before_break = e.num("WarichuCharsBeforeBreak").map(|v| v.max(0.0) as u32);
|
||||
a.warichu_chars_after_break = e.num("WarichuCharsAfterBreak").map(|v| v.max(0.0) as u32);
|
||||
if let Some(on) = e.boolean("RubyFlag") {
|
||||
a.ruby = Some(if on { e.prop("RubyString").unwrap_or_default() } else { String::new() });
|
||||
}
|
||||
|
||||
@@ -787,6 +787,32 @@ fn cjk_character_attributes_import_from_independent_xml_and_round_trip() {
|
||||
assert_eq!(a.leading_model, b.leading_model);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warichu_imports_from_idml_and_round_trips() {
|
||||
let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">
|
||||
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]">
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" Warichu="true" WarichuLines="3" WarichuSize="40" WarichuLineSpacing="1" WarichuAlignment="Left" WarichuCharsBeforeBreak="2" WarichuCharsAfterBreak="2"><Content>注釈</Content></CharacterStyleRange>
|
||||
</ParagraphStyleRange></Story></idPkg:Story>"#;
|
||||
let doc = import_idml(&fixture_with_story(story)).unwrap();
|
||||
let st = doc.stories.values().find(|s| s.text.contains("注釈")).unwrap();
|
||||
let a = &st.runs().next().unwrap().1.over;
|
||||
assert_eq!(a.warichu, Some(true));
|
||||
assert_eq!(a.warichu_lines, Some(3));
|
||||
assert_eq!(a.warichu_size, Some(40.0));
|
||||
assert_eq!(a.warichu_line_spacing, Some(1.0));
|
||||
assert_eq!(a.warichu_alignment, Some(designcraft_doc::cjk::WarichuAlignment::Left));
|
||||
assert_eq!(a.warichu_chars_before_break, Some(2));
|
||||
assert_eq!(a.warichu_chars_after_break, Some(2));
|
||||
let back = import_idml(&export_idml(&doc)).unwrap();
|
||||
let b = &back.stories.values().find(|s| s.text.contains("注釈")).unwrap().runs().next().unwrap().1.over;
|
||||
assert_eq!(a.warichu, b.warichu);
|
||||
assert_eq!(a.warichu_lines, b.warichu_lines);
|
||||
assert_eq!(a.warichu_size, b.warichu_size);
|
||||
assert_eq!(a.warichu_alignment, b.warichu_alignment);
|
||||
assert_eq!(a.warichu_chars_before_break, b.warichu_chars_before_break);
|
||||
assert_eq!(a.warichu_chars_after_break, b.warichu_chars_after_break);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_kerning_does_not_import_inactive_numeric_values() {
|
||||
for (attributes, expected) in [
|
||||
|
||||
@@ -49,7 +49,7 @@ fn text_rules(cs: &ComposedStory, ft: &FrameText, underline: bool) -> Vec<(desig
|
||||
if !on {
|
||||
continue;
|
||||
}
|
||||
let rect = rule.rect(g.x, g.x + g.adv, line.baseline);
|
||||
let rect = rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, line, g));
|
||||
match bars.last_mut() {
|
||||
Some((last_rule, last)) if last_rule == rule && (last.y0 - rect.y0).abs() < 1e-6 && (rect.x0 - last.x1).abs() < 0.5 => {
|
||||
last.x1 = rect.x1;
|
||||
|
||||
@@ -127,7 +127,7 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line
|
||||
let style = &cs.styles[g.style as usize];
|
||||
for (on, rule, bars) in [(style.underline, &style.underline_rule, &mut underlines), (style.strikethrough, &style.strike_rule, &mut strikes)] {
|
||||
if on {
|
||||
bars.push((rule.color.clone(), rule.tint, rule.rect(g.x, g.x + g.adv, l.baseline)));
|
||||
bars.push((rule.color.clone(), rule.tint, rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, l, g))));
|
||||
}
|
||||
}
|
||||
let outline = db.outline(&g.face, g.gid);
|
||||
|
||||
@@ -986,32 +986,8 @@ fn draw_text_selection(
|
||||
if s > e || (s == e && !(l.range.end < range.end && e == l.range.end)) {
|
||||
continue;
|
||||
}
|
||||
let quad = |x0: f64, x1: f64| {
|
||||
[
|
||||
Point::new(x0, l.baseline - l.ascent),
|
||||
Point::new(x1, l.baseline - l.ascent),
|
||||
Point::new(x1, l.baseline + l.descent),
|
||||
Point::new(x0, l.baseline + l.descent),
|
||||
]
|
||||
};
|
||||
if l.glyphs.iter().any(|g| g.rtl) {
|
||||
// Mixed directions: the selected glyphs' boxes, joined where they touch.
|
||||
let mut spans: Vec<(f64, f64)> =
|
||||
l.glyphs.iter().filter(|g| g.len > 0 && g.byte >= s && g.byte < e).map(|g| (g.x, g.x + g.adv)).collect();
|
||||
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut merged: Vec<(f64, f64)> = Vec::new();
|
||||
for (a, b) in spans {
|
||||
match merged.last_mut() {
|
||||
Some(m) if a <= m.1 + 0.5 => m.1 = m.1.max(b),
|
||||
_ => merged.push((a, b)),
|
||||
}
|
||||
}
|
||||
quads.extend(merged.into_iter().map(|(a, b)| quad(a, b)));
|
||||
} else {
|
||||
let x0 = compose::caret_x(l, s);
|
||||
let x1 = if e == l.range.end && range.end > l.range.end { l.end_x.max(x0 + 3.0) } else { compose::caret_x(l, e) };
|
||||
quads.push(quad(x0, x1));
|
||||
}
|
||||
let past = e == l.range.end && range.end > l.range.end;
|
||||
quads.extend(compose::highlight_quads(l, s, e, past));
|
||||
for (gi, g) in l.glyphs.iter().enumerate() {
|
||||
if g.visible && g.len > 0 && g.byte >= s && g.byte < e {
|
||||
glyphs.push((li, gi));
|
||||
@@ -1072,15 +1048,11 @@ fn draw_cell_selection(
|
||||
if s > e || (s == e && !(l.range.end < range.end && e == l.range.end)) {
|
||||
continue;
|
||||
}
|
||||
let x0 = compose::caret_x(l, s);
|
||||
let x1 = if e == l.range.end && range.end > l.range.end { l.end_x.max(x0 + 3.0) } else { compose::caret_x(l, e) };
|
||||
let q = [
|
||||
xf.to_screen(m * Point::new(x0, l.baseline - l.ascent)),
|
||||
xf.to_screen(m * Point::new(x1, l.baseline - l.ascent)),
|
||||
xf.to_screen(m * Point::new(x1, l.baseline + l.descent)),
|
||||
xf.to_screen(m * Point::new(x0, l.baseline + l.descent)),
|
||||
];
|
||||
painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE));
|
||||
let past = e == l.range.end && range.end > l.range.end;
|
||||
for q in compose::highlight_quads(l, s, e, past) {
|
||||
let poly = q.map(|p| xf.to_screen(m * p));
|
||||
painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE));
|
||||
}
|
||||
}
|
||||
}
|
||||
if ts.is_caret()
|
||||
|
||||
@@ -88,6 +88,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
("Tate-Chu-Yoko", ["Tate-Chu-Yoko", "Tate-Chu-Yoko", "Tate-Chu-Yoko", "縦中横", "纵中横"]),
|
||||
("Ruby…", ["Ruby …", "Ruby…", "Ruby…", "ルビ…", "旁注…"]),
|
||||
("Kenten", ["Kenten", "Kenten", "Kenten", "圏点", "圈点"]),
|
||||
("Warichu", ["Warichu", "Warichu", "Warichu", "割り注", "割注"]),
|
||||
("Font", ["Schriftart", "Police", "Fuente", "フォント", "字体"]),
|
||||
("Size", ["Schriftgrad", "Corps", "Tamaño", "サイズ", "大小"]),
|
||||
("Character", ["Zeichen", "Caractère", "Carácter", "文字", "字符"]),
|
||||
@@ -2996,6 +2997,7 @@ mod tests {
|
||||
("Tate-Chu-Yoko", "縦中横"),
|
||||
("Ruby…", "ルビ…"),
|
||||
("Kenten", "圏点"),
|
||||
("Warichu", "割り注"),
|
||||
] {
|
||||
assert_eq!(tr("ja", en), ja);
|
||||
}
|
||||
|
||||
@@ -1051,6 +1051,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Tate-Chu-Yoko", "نص أفقي داخل النص العمودي"),
|
||||
("Ruby…", "روبي…"),
|
||||
("Kenten", "علامات توكيد"),
|
||||
("Warichu", "واريشو"),
|
||||
("Tabs", "علامات الجدولة"),
|
||||
("Story", "القصة"),
|
||||
("Create Outlines", "إنشاء حدود خارجية"),
|
||||
|
||||
@@ -437,6 +437,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"cmd:type.tateChuYoko",
|
||||
"ui:app.rubyDialog",
|
||||
"cmd:type.kenten",
|
||||
"cmd:type.warichu",
|
||||
">Track Changes",
|
||||
"cmd:changes.track",
|
||||
"cmd:changes.acceptAll",
|
||||
|
||||
+12
-1
@@ -122,7 +122,18 @@ All set after line layout like today's ruby, but able to affect line height:
|
||||
- **Kenten**: position, size, alignment, colour, font (the kinds and a custom character are
|
||||
there).
|
||||
- **Warichu**: an inline run set in 2+ smaller lines inside one line height, with size, line
|
||||
spacing, alignment and breaking rules.
|
||||
spacing, alignment and breaking rules. Type › Warichu (`type.warichu`) does this after the
|
||||
line is broken: the run is scaled and stacked, and the rest of that line closes up. It does
|
||||
not pull more characters onto the line. The first small line keeps at least
|
||||
`WarichuCharsBeforeBreak` glyphs and the last at least `WarichuCharsAfterBreak`; the glyphs
|
||||
are split so those lines are as close in width as the minimums allow. Line spacing of 0 keeps
|
||||
the baselines one small em apart, a positive value adds to that em, and a negative value
|
||||
tightens it down to a shared baseline. The caret, the selection, and hit testing follow the
|
||||
small line the character sits on, and Up and Down move between those lines before leaving the
|
||||
parent line. Underlines follow the small baseline. Right-to-left and tate-chu-yoko runs are
|
||||
left as body text. IDML reads and writes `Warichu`, `WarichuLines`, `WarichuSize`,
|
||||
`WarichuLineSpacing`, `WarichuAlignment`, `WarichuCharsBeforeBreak` and
|
||||
`WarichuCharsAfterBreak`.
|
||||
- **Tate-chu-yoko**: scale-to-fit-em, automatic TCY for runs of N digits (with or without
|
||||
Latin), rensuji (offsets are there).
|
||||
- **Shatai** (oblique in the CJK sense: angle and magnification, keeping the em box),
|
||||
|
||||
Reference in new issue
Block a user