Merge pull request #1946 from yukincom/codex/vertical-punctuation-upright

G4: keep fullwidth punctuation upright without vertical alternates
This commit is contained in:
BFlat
2026-10-16 04:34:07 +07:00
committed by GitHub
7 changed files with 104 additions and 8 deletions
+3 -1
View File
@@ -571,6 +571,8 @@ const QUARTER_TURN: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
/// forms come from the font's `vert` feature); Latin letters, digits and other characters lie on
/// their side. Brackets, the long vowel mark and similar marks that need a vertical form lie on
/// their side when the font has none (Unicode's Vertical_Orientation Tr).
/// Fullwidth !/? instead keep the nominal glyph upright (Tu fallback, UAX #50 §3.1):
/// <https://www.unicode.org/reports/tr50/#vo>.
fn stands_upright(g: &SGlyph) -> bool {
let c = g.ch as u32;
let upright = matches!(c,
@@ -587,7 +589,7 @@ fn stands_upright(g: &SGlyph) -> bool {
}
let needs_vertical_form = matches!(c,
0x3001 | 0x3002 | 0x3008..=0x3011 | 0x3013..=0x301F | 0x3030 | 0x30A0 | 0x30FC
| 0xFE50..=0xFE52 | 0xFE59..=0xFE5E | 0xFF01 | 0xFF08 | 0xFF09 | 0xFF0C | 0xFF0E | 0xFF1A | 0xFF1B | 0xFF1F
| 0xFE50..=0xFE52 | 0xFE59..=0xFE5E | 0xFF08 | 0xFF09 | 0xFF0C | 0xFF0E | 0xFF1A | 0xFF1B
| 0xFF3B | 0xFF3D | 0xFF3F | 0xFF5B..=0xFF60 | 0xFFE3);
// Without a vertical alternate the shaped glyph is the nominal one.
!needs_vertical_form || g.gid != g.face.glyph_for(g.ch)
+43
View File
@@ -175,6 +175,49 @@ pub fn vertical_font(vorg: bool) -> Option<Vec<u8>> {
Some(builder.build())
}
/// The family of [`vertical_punctuation_font`].
pub const VERTICAL_PUNCTUATION_FAMILY: &str = "Punctuationtest Sans";
/// A test-only cmap over bundled Source Sans 3: fullwidth !/? use the ASCII glyphs,
/// 「 uses L and ー uses a hyphen. Optionally `vert` substitutes I/J for !/?, so tests
/// exercise both nominal fallbacks and actual vertical substitutions without OS fonts.
pub fn vertical_punctuation_font(alternates: bool) -> Option<Vec<u8>> {
use write_fonts::tables::cmap::Cmap;
use write_fonts::tables::gsub::{Gsub, SingleSubstFormat2, SubstitutionLookup, SubstitutionLookupList};
use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord};
use write_fonts::types::GlyphId16;
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
let font = skrifa::FontRef::new(BASE).ok()?;
let charmap = font.charmap();
let mapping = [('!', '!'), ('?', '?'), ('「', 'L'), ('ー', '-'), ('!', '!'), ('?', '?'), ('I', 'I'), ('J', 'J'), ('L', 'L'), ('-', '-')];
let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32()))));
let cmap = Cmap::from_mappings(mappings.collect::<Option<Vec<_>>>()?).ok()?;
let family = VERTICAL_PUNCTUATION_FAMILY;
let name = name_table(&[(1, family), (2, "Regular"), (4, "Punctuationtest Sans Regular"), (6, "PunctuationtestSans-Regular")])?;
let mut builder = FontBuilder::new();
builder.add_table(&cmap).ok()?;
builder.add_raw(Tag::new(b"name"), name);
if alternates {
let gid = |c| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?));
let mut pairs = [(gid('!')?, gid('I')?), (gid('?')?, gid('J')?)];
pairs.sort_by_key(|p| p.0);
let coverage = CoverageFormat1::new(pairs.iter().map(|p| p.0).collect());
let subst = SingleSubstFormat2::new(coverage.into(), pairs.iter().map(|p| p.1).collect());
let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(vec![0])), vec![]))]);
let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(b"vert"), Feature::new(None, vec![0]))]);
let lookups = SubstitutionLookupList::new(vec![SubstitutionLookup::Single(Lookup::new(Default::default(), vec![subst.into()]))]);
builder.add_table(&Gsub::new(scripts, features, lookups)).ok()?;
}
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if tag != Tag::new(b"GSUB") && !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// The family of [`twin_font`]: two versions of one family and style.
pub const TWIN_FAMILY: &str = "Twintest Sans";
+44
View File
@@ -43,6 +43,50 @@ fn upright_marks_stand_and_letters_lie_on_their_side() {
assert!(vl.width() > vl.height(), "a tall l lies across the column");
}
/// Unicode Vertical_Orientation Tu: without a vertical alternate, fullwidth !/? stay upright.
/// Tr controls (a bracket and the long vowel mark) still rotate when they have no alternate.
#[test]
fn fullwidth_punctuation_stays_upright_without_vertical_alternates() {
check_fullwidth_punctuation(false);
}
#[test]
fn fullwidth_punctuation_keeps_vertical_alternates_upright() {
check_fullwidth_punctuation(true);
}
fn check_fullwidth_punctuation(alternates: bool) {
use crate::test_fonts::{VERTICAL_PUNCTUATION_FAMILY, vertical_punctuation_font};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(vertical_punctuation_font(alternates).unwrap());
let style = CharStyle { font_family: VERTICAL_PUNCTUATION_FAMILY.into(), size: 20.0, ..CharStyle::default() };
let face = db.face(VERTICAL_PUNCTUATION_FAMILY, "Regular").unwrap();
for (ch, nominal, alternate) in [('!', '!', 'I'), ('?', '?', 'J'), ('「', 'L', 'L'), ('ー', '-', '-')] {
let upright = matches!(ch, '!' | '?');
let expected = if alternates { alternate } else { nominal };
let h = layout(&db, &TextObject::point(Point::ZERO, &expected.to_string(), style.clone()));
for area in [false, true] {
let mut t = vertical_styled(&ch.to_string(), style.clone());
if area {
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1)) };
}
let v = layout(&db, &t);
assert_eq!(v.glyphs.len(), 1, "{ch} area {area}");
assert!(!v.overflow);
let g = &v.glyphs[0];
assert_eq!(g.font_id, face.id());
assert_eq!(g.gid, face.glyph_for(expected), "{ch}: the expected nominal or vert glyph");
let reference = if upright { h.glyphs[0].xf } else { Affine::rotate(std::f64::consts::FRAC_PI_2) * h.glyphs[0].xf };
for (actual, expected) in g.xf.as_coeffs().iter().zip(reference.as_coeffs().iter()).take(4) {
assert!((actual - expected).abs() < 1e-9, "{ch} area {area}, alternates {alternates}: {:?} vs {reference:?}", g.xf);
}
}
// Vertical substitutions must not change horizontal type.
let h = layout(&db, &TextObject::point(Point::ZERO, &ch.to_string(), style.clone()));
assert_eq!(h.glyphs[0].gid, face.glyph_for(nominal));
}
}
#[test]
fn caret_and_selection_turn_with_the_columns() {
let l = layout(FontDb::global(), &vertical("§§§"));
+4 -1
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -1,6 +1,6 @@
# Type parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x)
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
@@ -18,7 +18,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Feature | Status | Notes |
|---|---|---|
| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802); `text.create` and `text.createInPath` take `justify` (#1752) |
| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802); `text.create` and `text.createInPath` take `justify` (#1752) |
| Area Type Options (rows, columns, inset, first baseline, Auto Size, Shrink Text to Fit) | done | the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) |
| Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444) |
| Type on a Path effects and options; brackets | done | #429 |
@@ -58,6 +58,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. |
| 2026-10-14 | minor | Tab leaders are drawn: the leader's characters fill the tab's gap in the tab's font and size, whole copies on a grid from the frame's edge so leaders line up across lines (fixes #1877); scores and hours unchanged |
| 2026-10-14 | minor | Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Score unchanged (estimated) |
| 2026-10-14 | minor | Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656); Select › Same matches type on its characters' fill and stroke (#1746, #1737); `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748); scores and hours unchanged |