diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index a72d3cc93..26a790bf2 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -21,8 +21,8 @@ use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject}; use vectorcraft_effects::stroke::{self, WrittenShape}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect}; -use vectorcraft_text::TextLayout; use vectorcraft_text::embed::Embedding; +use vectorcraft_text::{ListMarker, TextLayout}; use crate::forms::Mark; use crate::lab_spot::{find, rfind}; @@ -694,8 +694,11 @@ struct GlyphLine { /// The glyph space (krilla's, y down, at `size`) → text space, at the line's start. frame: Affine, /// Glyph id, pen position and advance along the line (in glyph space), and the characters it - /// stands for (bytes of the plain text). + /// stands for (bytes of the plain text, or of the list marker's text). glyphs: Vec<(u32, f64, f64, std::ops::Range)>, + /// The list marker the glyphs belong to, by its first glyph's index in the layout + /// ([`TextLayout::marker_of`]); `None` for glyphs of the plain text. + marker: Option, } /// Most pixels along a side of a freeform gradient's image (its colour field is smooth). @@ -1780,7 +1783,9 @@ impl Exporter<'_> { /// COLR, SVG), with `images`, are written as text too, which draws their images or layers; the /// indices of those it can't write or draw (SVG glyphs) are returned with the outlines. The /// hyphen the layout adds where it breaks a line inside a word ([`break_hyphen`]) is written - /// as the character its glyph stands for, on its own after the glyphs before it. + /// as the character its glyph stands for, on its own after the glyphs before it. The glyphs of + /// a list marker are written as the marker's characters ([`marker_chars`]), apart from the + /// plain text's glyphs; a marker glyph that stands for none of them is drawn as a path. /// /// `bent`: `Some(false)` writes the glyphs bent along a path ([`vectorcraft_text::GlyphBend`]) /// as outlines, not text (text is set straight); `Some(true)` writes only those, as text where @@ -1802,6 +1807,11 @@ impl Exporter<'_> { // stand for no character of it, the characters a line-break hyphen can stand for. let written: Cow = if layout.glyphs.iter().any(|g| g.run == run && g.len == 0) { Cow::Owned(format!("{text}{BREAK_HYPHENS}")) } else { Cow::Borrowed(text) }; + let written_text: &str = &written; + // The text a line's glyphs stand for: `written`, or its list marker's characters. + let text_of = |l: &GlyphLine| l.marker.and_then(|m| layout.marker_of(m)).map_or(written_text, |m| m.text.as_str()); + // The characters of the list marker being written that each of its glyphs stands for. + let mut marker: Option<(usize, Vec>>)> = None; for (gi, g) in layout.glyphs.iter().enumerate().filter(|(_, g)| g.run == run) { let coloured = g.image.is_some() || g.colour.is_some(); if coloured && !images { @@ -1815,11 +1825,27 @@ impl Exporter<'_> { (Some(true), false) => continue, _ => {} } - // A list marker's glyphs stand for no character of the text either; they are not - // line-break hyphens, whatever the marker. - let hyphen = if layout.marker_of(gi).is_some() { None } else { break_hyphen(g) }; - let range = hyphen.clone().map_or(g.byte..g.byte + g.len, |h| text.len() + h.start..text.len() + h.end); - let ch = written.get(range.clone()).and_then(|t| t.chars().next()); + // Of the glyphs with no length, only a list marker's and line-break hyphens are written + // as text; the others, such as kashidas, are drawn as paths. A list marker's glyphs are + // not line-break hyphens, whatever the marker. + let in_marker = (g.len == 0).then(|| layout.marker_of(gi)).flatten(); + let hyphen = if in_marker.is_some() { None } else { break_hyphen(g) }; + let (range, ch) = match in_marker { + Some(m) => { + if marker.as_ref().is_none_or(|(start, _)| *start != m.glyphs.start) { + marker = Some((m.glyphs.start, marker_chars(layout, m))); + } + let range = marker.as_ref().and_then(|(start, chars)| chars.get(gi.checked_sub(*start)?).cloned().flatten()); + let ch = range.clone().and_then(|r| m.text.get(r)).and_then(|t| t.chars().next()); + (range.unwrap_or(0..0), ch) + } + None => { + let range = hyphen.clone().map_or(g.byte..g.byte + g.len, |h| text.len() + h.start..text.len() + h.end); + let ch = written.get(range.clone()).and_then(|t| t.chars().next()); + (range, ch) + } + }; + let marker_at = in_marker.map(|m| m.glyphs.start); // Spaces are written too (text extraction needs them); control characters and soft // hyphens draw nothing. let blank = g.outline.elements().is_empty() && !coloured; @@ -1852,18 +1878,22 @@ impl Exporter<'_> { let frame = g.xf * Affine::scale(1.0 / sy); // Its pen position along the frame's baseline, when it continues the line (a // line-break hyphen never does). - let x = line.as_ref().filter(|l| hyphen.is_none() && l.face == g.font_id && (l.size - size).abs() < 1e-6).and_then(|l| { - let [a, b, c, d, e, f] = (l.frame.inverse() * frame).as_coeffs(); - let same = (a - 1.0).abs() < 1e-6 && b.abs() < 1e-6 && c.abs() < 1e-6 && (d - 1.0).abs() < 1e-6 && f.abs() < 1e-4; - same.then_some(e) - }); + let x = line + .as_ref() + .filter(|l| hyphen.is_none() && l.face == g.font_id && (l.size - size).abs() < 1e-6 && l.marker == marker_at) + .and_then(|l| { + let [a, b, c, d, e, f] = (l.frame.inverse() * frame).as_coeffs(); + let same = (a - 1.0).abs() < 1e-6 && b.abs() < 1e-6 && c.abs() < 1e-6 && (d - 1.0).abs() < 1e-6 && f.abs() < 1e-4; + same.then_some(e) + }); let x = match x { Some(x) => x, None => { if let Some(l) = line.take() { - self.draw_glyph_line(s, l, &written, fill, layout.bounds); + let text = text_of(&l); + self.draw_glyph_line(s, l, text, fill, layout.bounds); } - line = Some(GlyphLine { face: g.font_id, font, size, frame, glyphs: vec![] }); + line = Some(GlyphLine { face: g.font_id, font, size, frame, glyphs: vec![], marker: marker_at }); 0.0 } }; @@ -1880,7 +1910,8 @@ impl Exporter<'_> { } } if let Some(l) = line { - self.draw_glyph_line(s, l, &written, fill, layout.bounds); + let text = text_of(&l); + self.draw_glyph_line(s, l, text, fill, layout.bounds); } (rest, unwritten) } @@ -2129,6 +2160,35 @@ fn break_hyphen(g: &vectorcraft_text::PositionedGlyph) -> Option Vec>> { + let db = vectorcraft_text::FontDb::global(); + let mut left: Vec<(std::ops::Range, char)> = m.text.char_indices().map(|(i, c)| (i..i + c.len_utf8(), c)).collect(); + let mut chars = vec![None; m.glyphs.len()]; + let mut unmapped = vec![]; + for (k, gi) in m.glyphs.clone().enumerate() { + let Some(g) = layout.glyphs.get(gi).filter(|g| gi != m.tab && g.gid != 0) else { continue }; + let face = db.face_by_id(g.font_id); + match (left.iter().position(|(_, c)| face.as_ref().is_some_and(|f| f.glyph_for(*c) == g.gid)), chars.get_mut(k)) { + (Some(at), Some(slot)) => *slot = Some(left.remove(at).0), + _ => unmapped.push(k), + } + } + if unmapped.len() == left.len() { + for (k, (range, _)) in unmapped.into_iter().zip(left) { + if let Some(slot) = chars.get_mut(k) { + *slot = Some(range); + } + } + } + chars +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pdf/src/tests_realtext.rs b/crates/pdf/src/tests_realtext.rs index 5d043e724..e866ba5cb 100644 --- a/crates/pdf/src/tests_realtext.rs +++ b/crates/pdf/src/tests_realtext.rs @@ -5,8 +5,9 @@ use std::sync::Arc; use serde_json::json; use vectorcraft_color::{Color, Gradient, GradientPaint, Paint}; -use vectorcraft_doc::{CharStyle, Document, Effect, Node, NodeKind, TextKind, TextObject, TextRun}; -use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect}; +use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList}; +use vectorcraft_doc::{CharStyle, Document, Effect, Node, NodeKind, ParaStyle, TextKind, TextObject, TextRun}; +use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, shapes}; use vectorcraft_text::{FALLBACK_FAMILY, FontDb}; use crate::*; @@ -303,6 +304,196 @@ fn real_text_lands_within_half_a_point_of_the_outlines() { } } +/// Area type 200 points wide holding `text`, its paragraphs in lists `lists`, with an 18 pt +/// hanging indent. +fn listed(text: &str, lists: &[Option]) -> TextObject { + let mut t = TextObject::point(Point::ZERO, text, style(12.0)); + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(20.0, 20.0, 220.0, 280.0)) }; + t.xf = Affine::IDENTITY; + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let styles = lists.iter().map(|&list| ParaStyle { list, ..t.para.clone() }).collect(); + t.set_paragraph_styles(styles); + t +} + +/// The origins (document space) of the glyphs of each list marker of the type objects of `d`, in +/// the order they are drawn, the marker's tab left out. +fn marker_origins(d: &Document) -> Vec> { + let mut out = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + let lay = vectorcraft_text::layout(FontDb::global(), t); + for m in &lay.markers { + out.push(m.glyphs.clone().filter(|&i| i != m.tab).map(|i| t.xf * lay.glyphs[i].origin).collect()); + } + } + }); + out +} + +/// Each glyph of the type objects of `d` that stands for characters of its text: the first of +/// them and the glyph's origin in document space. +fn glyph_chars(d: &Document) -> Vec<(char, Point)> { + let mut out = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + let text = t.plain_text(); + for g in vectorcraft_text::layout(FontDb::global(), t).glyphs.iter().filter(|g| g.len > 0) { + if let Some(c) = text.get(g.byte..).and_then(|s| s.chars().next()) { + out.push((c, t.xf * g.origin)); + } + } + } + }); + out +} + +/// Does `text` hold `a` before `b`? +fn before(text: &str, a: &str, b: &str) -> bool { + matches!((text.find(a), text.find(b)), (Some(x), Some(y)) if x < y) +} + +/// `back`, the export of `d` reopened, has glyphs of the characters `drawn` (each list marker's, +/// in the order the canvas draws them) within `near` points of where `d` draws its markers' +/// glyphs. +fn assert_markers_in_place(d: &Document, back: &Document, drawn: &[&str], near: f64) { + let want = marker_origins(d); + assert_eq!(want.len(), drawn.len(), "{drawn:?}"); + let got = glyph_chars(back); + for (origins, chars) in want.iter().zip(drawn) { + assert_eq!(origins.len(), chars.chars().count(), "{chars}"); + for (p, c) in origins.iter().zip(chars.chars()) { + assert!(got.iter().any(|(gc, gp)| *gc == c && gp.distance(*p) < near), "{c:?} at {p:?}: {got:?}"); + } + } +} + +/// List markers (Paragraph panel › Bullets and Numbered List) are written as text: reopened, the +/// export holds each bullet and number as characters, before its paragraph's text and where the +/// canvas draws them, and the page looks as it does with outlined type. +#[test] +fn list_markers_are_written_as_text_where_the_canvas_draws_them() { + let number = Some(ParaList::numbered(NumberStyle::Arabic)); + for (bullet, character) in [(Bullet::Dot, "\u{2022}"), (Bullet::Dash, "\u{2013}"), (Bullet::Circle, "\u{25E6}"), (Bullet::Square, "\u{25AA}")] { + let bullet = Some(ParaList::bulleted(bullet)); + let t = listed("alpha beta gamma delta epsilon zeta eta theta\nbullet\nfirst\nsecond", &[bullet, bullet, number, number]); + let d = doc(vec![t]); + let back = import_as(&pdf(&d, false).bytes, TextAs::Text); + let all = texts(&back).concat(); + assert_eq!(all.matches(character).count(), 2, "{all}"); + assert!(before(&all, character, "alpha") && before(&all, "1.", "first") && before(&all, "2.", "second"), "{all}"); + assert_markers_in_place(&d, &back, &[character, character, "1.", "2."], 0.1); + let (real, outlined) = (ink(&import_as(&pdf(&d, false).bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines))); + let off = + [real.x0 - outlined.x0, real.y0 - outlined.y0, real.x1 - outlined.x1, real.y1 - outlined.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + assert!(off < 0.5, "{character}: {real:?} vs {outlined:?}"); + } +} + +/// A right-to-left numbered paragraph's number is written as its characters where the canvas +/// draws them (the period, then 1 and 2, from the left), and the paragraph's own text comes back +/// as it does without a list. +#[test] +fn a_right_to_left_number_is_written_as_text_where_the_canvas_draws_it() { + let hebrew = "שלום עולם"; + let mut plain = listed(hebrew, &[None]); + plain.edit_paras(None, |p| p.justify = vectorcraft_doc::Justify::Auto); + let mut numbered = plain.clone(); + numbered.edit_paras(None, |p| p.list = Some(ParaList::numbered(NumberStyle::Arabic))); + numbered.list_count = 11; + let d = doc(vec![numbered]); + let back = import_as(&pdf(&d, false).bytes, TextAs::Text); + // The reader tracks a reopened right-to-left line as a whole, and the gap before the number + // comes back as a space of the Hebrew font's width, so the number may sit up to a tenth of an + // em (1.2 pt) away; its characters keep their spacing to within 0.1 pt. + assert_markers_in_place(&d, &back, &[".12"], 1.2); + let drawn = marker_origins(&d).concat(); + let got = glyph_chars(&back); + let at = |c: char| got.iter().find(|(gc, _)| *gc == c).map(|(_, p)| p.x); + for (k, (a, b)) in [('.', '1'), ('1', '2')].into_iter().enumerate() { + let (Some(pa), Some(pb), Some(da), Some(db)) = (at(a), at(b), drawn.get(k), drawn.get(k + 1)) else { + panic!("{a:?} and {b:?} in {got:?}"); + }; + assert!(((pb - pa) - (db.x - da.x)).abs() < 0.1, "{a:?} to {b:?}: {} against {}", pb - pa, db.x - da.x); + } + let without = texts(&import_as(&pdf(&doc(vec![plain]), false).bytes, TextAs::Text)).concat(); + let all = texts(&back).concat(); + assert!(all.contains(without.trim()), "{all:?} holds {without:?}"); +} + +/// Type on a path writes its bullet as text where the canvas draws it along the path. +#[test] +fn a_bullet_on_a_path_is_written_as_text_where_the_canvas_draws_it() { + let mut t = TextObject::point(Point::ZERO, "along the arc", style(18.0)); + t.kind = TextKind::OnPath { path: arc(), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + let d = doc(vec![t]); + let back = import_as(&pdf(&d, false).bytes, TextAs::Text); + assert!(texts(&back).concat().contains('\u{2022}'), "{:?}", texts(&back)); + assert_markers_in_place(&d, &back, &["\u{2022}"], 0.3); +} + +/// Threaded frames write each marker their layouts draw as text, the number of the paragraph the +/// first frame splits included, in each frame where the canvas draws it. +#[test] +fn numbers_in_threaded_frames_are_written_as_text_in_each_frame() { + let numbered = + ParaStyle { list: Some(ParaList::numbered(NumberStyle::Arabic)), left_indent: 18.0, first_line_indent: -18.0, ..Default::default() }; + let frame = |r: Rect| { + let mut t = TextObject::point(Point::ZERO, "", style(12.0)); + t.kind = TextKind::Area { frame: shapes::rectangle(r) }; + t.xf = Affine::IDENTITY; + t + }; + let (mut a, mut b) = (frame(Rect::new(20.0, 20.0, 180.0, 80.0)), frame(Rect::new(200.0, 20.0, 360.0, 280.0))); + let text = format!("One\nTwo\n{}\nFour", "The quick brown fox jumps over the lazy dog. ".repeat(4)); + let story = vec![TextRun { text, style: style(12.0), inline: None }]; + let parts = vectorcraft_text::thread::distribute(FontDb::global(), &[&a, &b], &story, &vec![numbered; 4]); + for (f, part) in [&mut a, &mut b].into_iter().zip(&parts) { + f.runs = part.runs.clone(); + f.set_paragraph_styles(part.paras.clone()); + f.list_count = part.list_count; + } + let first = a.plain_text(); + assert!(first.contains("quick") && !first.ends_with('\n'), "the first frame splits the third paragraph: {first:?}"); + let d = doc(vec![a, b]); + let mut drawn: Vec = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + drawn.extend(vectorcraft_text::layout(FontDb::global(), t).markers.into_iter().map(|m| m.text)); + } + }); + assert!(drawn.contains(&"4.".to_string()), "{drawn:?}"); + let back = import_as(&pdf(&d, false).bytes, TextAs::Text); + assert_markers_in_place(&d, &back, &drawn.iter().map(String::as_str).collect::>(), 0.1); +} + +/// A number set in oldstyle figures (the first character's OpenType features) is written as its +/// digits, though the character map gives the digits other glyphs. The reopened type has no +/// OpenType features and sets lining figures, so only where each number starts is compared. +#[test] +fn a_number_in_oldstyle_figures_is_written_as_its_digits() { + let number = Some(ParaList::numbered(NumberStyle::Arabic)); + let mut t = listed("first\nsecond", &[number, number]); + for r in &mut t.runs { + r.style.features = vec!["onum".into()]; + } + let lay = vectorcraft_text::layout(FontDb::global(), &t); + let one = &lay.glyphs[lay.markers[0].glyphs.start]; + let face = FontDb::global().face_by_id(one.font_id).unwrap(); + assert_ne!(face.glyph_for('1'), one.gid, "an oldstyle 1, not the character map's"); + let d = doc(vec![t]); + let back = import_as(&pdf(&d, false).bytes, TextAs::Text); + let all = texts(&back).concat(); + assert!(before(&all, "1.", "first") && before(&all, "2.", "second"), "{all:?}"); + let got = glyph_chars(&back); + for (origins, digit) in marker_origins(&d).iter().zip(['1', '2']) { + assert!(got.iter().any(|(c, p)| *c == digit && p.distance(origins[0]) < 0.1), "{digit} at {:?}: {got:?}", origins[0]); + } +} + /// A run shifted two billion points off its baseline is left out, as real text or outlines, and /// the rest of the type is written. On a page 50 points high, the page's y flip puts the shifted /// run's baseline at -2,147,483,648, which the PDF writer panics on in builds with overflow checks. diff --git a/docs/mcp.md b/docs/mcp.md index 61583a8f2..7df4d90f5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4531,10 +4531,15 @@ after Remove Threading or Release Selection, in a frame converted to point type, duplicated, copied in the Layers panel, or taken out of a symbol or a placed file). A symbol made of a thread keeps the numbers in the symbol's art. -The canvas, EPS, PDF and Type › Create Outlines draw the marker; in PDF it is a path, not text. Live SVG text writes the -marker's characters before the paragraph's text, except type on a path with a list, which it writes as outlines with a -warning. DXF writes the marker as a TEXT entity of its own, its characters in the order they are drawn. Legacy `.ai` -writes the paragraph's text without the marker and warns that bulleted and numbered lists are left out. +The canvas, EPS, PDF and Type › Create Outlines draw the marker. PDF writes the marker as text of its own, where the +canvas draws it, and a PDF viewer selects, copies and finds its characters. Each of its glyphs stands for the marker +character that the glyph's font maps to that glyph in its character map. Glyphs that an OpenType feature such as +oldstyle figures replaced stand for the characters left over, in order, when there are as many of those glyphs as +characters left over. A marker glyph that stands for no character, such as the missing-glyph box when no font has a +glyph for it, is drawn as a path. Live SVG text writes the marker's characters before the paragraph's text, except +type on a path with a list, which it writes as outlines with a warning. DXF writes the marker as a TEXT entity of its +own, its characters in the order they are drawn. Legacy `.ai` writes the paragraph's text without the marker and warns +that bulleted and numbered lists are left out. Type › Bullets and Numbering › Apply Bullets and the canvas context menu's Bullets and Numbering › Apply Bullets run `type.applyBullets {bullet?}` on the selected type's paragraphs, or on those the Type tool's selection touches. Without diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bb9a5ed39..3bb9d232f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -792,6 +792,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Paragraph panel's Numbered List button, Type › Bullets and Numbering › Apply Numbers and the context menu's Apply Numbers (`text.setFormat {list, number}`, `type.applyNumbers`) make paragraphs a numbered list with the documented presets (1, A, a, I, i, 01, 001, and None, which turns the list off; [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html), [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): each paragraph starts with its number and a period, counted through the list paragraphs of the story and its threaded frames, with the text and indents as in a bulleted list; letters go on from Z to AA, and roman numerals go on from III to IV, an empty paragraph is not numbered, and a paragraph in no list starts the count again at 1. Type › Bullets and Numbering › Convert to Text (`type.convertListToText`, `text.convertListToText`) makes the bullets and numbers of the list paragraphs editable characters of their text ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html), [December 2022 release notes](https://web.archive.org/web/20260203094741/https://helpx.adobe.com/illustrator/using/whats-new/2023-1.html)): each marker becomes its characters and a tab at the start of its paragraph, in the marker's font, size and color, the list is turned off, and a left tab stop at the left indent keeps the text after the tab where it started; threaded type keeps the numbers its story counted. The Type tool starts a list from the characters [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html) names (`text.startList`): a space typed on its own after `1.`, `1)`, `A.` or `A)` at the start of a paragraph in no list makes the paragraph a numbered list in 1, 2, 3 or A, B, C, and a space typed after `*`, `#`, `>` or `+` makes it a bulleted list with the filled dot. The typed characters are removed, and one Undo puts them back as text with no list. The same characters later in a paragraph, and pasted list characters, start nothing. + PDF export writes bullets and numbers as text, in the glyphs and at the places the canvas draws them, in area type, type on a path, right-to-left paragraphs and threaded frames, and a PDF viewer selects, copies and finds them; a marker glyph that stands for no character is drawn as a path. The Character panel menu's Character Direction (with the Indic options; `text.setFormat {charDirection}`, `text.setRangeStyle {charDirection}`) sets the selected characters left to right or right to left inside their paragraph, as a Unicode bidi embedding: a date set Left to Right keeps its order in right-to-left text, and a word set Right to Left keeps the punctuation after it on its left. Default (the default) leaves the characters to the bidi algorithm; character styles and the native file keep the setting. In vertical type, the Tabs panel names its alignment buttons Top-Justified Tab, Center-Justified Tab, Bottom-Justified Tab and Decimal-Justified Tab, in their tool tips and for screen readers, and labels the position field Y. In horizontal type the buttons keep the names Left-Justified Tab and Right-Justified Tab, and the field keeps the label X. The Tabs panel's ruler is numbered in the General unit with the canvas rulers' label steps and a tick every tenth of a step. With the panel menu's Snap to Unit on, or Shift held, a tab stop placed by a click on the ruler or dragged on it lands on the nearest tick (`text.tabs.set`).