From a2379634f485b75479f70b205416211f700e205d Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 14:39:07 +0900 Subject: [PATCH 001/132] Record which rule produced each braille cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking why a word came out the way it did meant reading the rule engine and guessing. The encoder now offers to say so itself: encode_with_trace returns the cells alongside a Trace, and the Trace names, for every cell, the rule that wrote it. Tracing is opt-in. encode never builds a Trace, so the untraced path keeps its shape, and the numbers say the same: fixtures stay 5141 of 5141, the corpus stays at 455,975 of 467,121, and the marker bench still reads 837 / 145 / 305 / 398. Attribution is partitioned by the engine that owns the input, because the encoder is really several engines and a caller must be able to tell an uninstrumented one from a rule that declined to fire. Korean syllables are split to the article rather than reported as one composite entry, so 안 names 제6항 for its vowel and 제3항 for its 받침 instead of naming the syllable rule twice. A rule that matched and then skipped produced nothing and is not recorded: that it ran and that it explains the output are different claims. Cells no rule object writes are still accounted for. The blank between two words, a pre-encoded run whose token rule declared no article, and 제29항's roman indicator, continuation and terminator each name themselves through the emitter, so an unexplained cell means a genuine gap rather than a structural one. Over all 467,112 corpus sentences the trace now explains every one of the 88,927,183 cells. The one shape that had been slipping through was 제35항's numeric bridge resuming into a lowercase a-j, where UEB 6.5.2 makes the emitter write a continuation cell — 298 of them, plus two roman indicators on the same path. What remains unexplained is the capitals and grade-1 indicators on the pure-UEB path, where attribution places whole attempts of the contraction search and an indicator belongs to no attempt. A Korean document never reaches it; exactly one corpus sentence takes that path at all. The counts are pinned in a test rather than absorbed into a catch-all slot, because a slot that swallows anything unclaimed would make the gap unmeasurable. --- apps/landing/src/app/RuleTrace.tsx | 272 +++++++ apps/landing/src/app/Trans.tsx | 32 +- libs/braillify/src/encoder.rs | 57 +- libs/braillify/src/korean_char.rs | 90 ++- libs/braillify/src/lib.rs | 437 +++++++++- libs/braillify/src/rules/context.rs | 9 + libs/braillify/src/rules/emit.rs | 181 ++++- libs/braillify/src/rules/engine.rs | 97 ++- .../src/rules/english_ueb/contraction.rs | 34 + .../braillify/src/rules/english_ueb/engine.rs | 19 +- .../rules/english_ueb/engine/word_methods.rs | 17 +- libs/braillify/src/rules/english_ueb/mod.rs | 263 ++++++ .../src/rules/english_ueb/rule_10_11.rs | 12 + .../src/rules/english_ueb/rule_10_3.rs | 12 + .../src/rules/english_ueb/rule_10_6_8.rs | 12 + .../src/rules/english_ueb/rule_10_6_middle.rs | 12 + .../rules/english_ueb/rule_10_6_restricted.rs | 12 + .../src/rules/english_ueb/rule_10_7.rs | 12 + .../src/rules/english_ueb/rule_10_7_pron.rs | 12 + .../src/rules/english_ueb/rule_10_7_struct.rs | 12 + .../src/rules/english_ueb/rule_10_8.rs | 12 + .../src/rules/english_ueb/rule_10_9.rs | 57 +- libs/braillify/src/rules/korean/rule_72.rs | 4 + .../braillify/src/rules/korean/rule_korean.rs | 20 +- libs/braillify/src/rules/math/encoder.rs | 47 +- .../src/rules/math/math_token_rule.rs | 59 +- libs/braillify/src/rules/math/mod.rs | 67 ++ libs/braillify/src/rules/math/rule_1.rs | 12 + libs/braillify/src/rules/math/rule_12.rs | 36 + libs/braillify/src/rules/math/rule_18.rs | 12 + libs/braillify/src/rules/math/rule_19.rs | 12 + libs/braillify/src/rules/math/rule_2.rs | 12 + libs/braillify/src/rules/math/rule_47.rs | 12 + libs/braillify/src/rules/math/rule_53.rs | 12 + libs/braillify/src/rules/math/rule_54.rs | 12 + libs/braillify/src/rules/math/rule_57.rs | 12 + libs/braillify/src/rules/math/rule_6.rs | 12 + libs/braillify/src/rules/math/rule_7.rs | 48 ++ libs/braillify/src/rules/math/rule_8.rs | 12 + libs/braillify/src/rules/mod.rs | 1 + libs/braillify/src/rules/token_engine.rs | 42 +- libs/braillify/src/rules/token_rule.rs | 19 + .../src/rules/token_rules/digital_notation.rs | 12 + .../src/rules/token_rules/emphasis_ring.rs | 13 + .../english_dominant_korean_wrap.rs | 13 + .../token_rules/historical_gloss_spacing.rs | 12 + .../src/rules/token_rules/inline_fraction.rs | 12 + .../src/rules/token_rules/latex_fraction.rs | 12 + .../token_rules/latex_math/merge_rule.rs | 12 + .../src/rules/token_rules/math_expression.rs | 13 + .../rules/token_rules/middle_dot_spacing.rs | 49 ++ .../token_rules/middle_korean_detector.rs | 12 + .../src/rules/token_rules/normalize.rs | 25 + .../src/rules/token_rules/quote_attachment.rs | 12 + .../src/rules/token_rules/roman_numeral.rs | 13 + .../src/rules/token_rules/rule_33_citation.rs | 13 + .../rule_73_appendix_placeholder.rs | 13 + .../src/rules/token_rules/spacing.rs | 25 + .../rules/token_rules/uppercase_passage.rs | 12 + .../src/rules/token_rules/word_shortcut.rs | 13 + libs/braillify/src/rules/trace.rs | 757 ++++++++++++++++++ packages/node/src/lib.rs | 86 ++ 62 files changed, 3162 insertions(+), 103 deletions(-) create mode 100644 apps/landing/src/app/RuleTrace.tsx create mode 100644 libs/braillify/src/rules/trace.rs diff --git a/apps/landing/src/app/RuleTrace.tsx b/apps/landing/src/app/RuleTrace.tsx new file mode 100644 index 00000000..5801fb02 --- /dev/null +++ b/apps/landing/src/app/RuleTrace.tsx @@ -0,0 +1,272 @@ +'use client' + +import { Box, Flex, Text, VStack } from '@devup-ui/react' +import type { TraceResult } from 'braillify' + +/** 한 번에 그리는 규칙 행의 최대 개수. 키 입력마다 다시 그리므로 상한을 둔다. */ +const MAX_VISIBLE_RULES = 120 + +/** `kind` 원문 → 화면 표기. 모르는 값이면 원문을 그대로 보여준다. */ +const KIND_LABEL: Record = { + korean: '한글', + jamo: '자모', + token: '기호', + math: '수학', + 'english-ueb': '영어', + emitter: '구조', +} + +/** + * 규칙이 하나도 잡히지 않는 경로별 설명. 점역 자체는 정상이므로 + * "적용된 규칙이 없다"고 읽히면 안 된다. + */ +const NO_RULE_NOTICE: Record = { + 'english-ueb': + '이 낱말은 축약 규칙 탐색이 아니라 낱말 기호표로 점역되어, 규칙 단위로 나눌 수 없습니다.', +} + +/** 출력 점자의 일부를 만들어 낸 규칙 하나. WASM 객체를 평범한 값으로 옮긴 것. */ +export interface TraceRule { + section: string + name: string + description: string + kind: string + start: number + end: number + braille: string +} + +/** + * 한 번의 점역 결과 스냅샷. + * + * - `idle` — 입력이 없거나 WASM이 아직 로드되지 않음. 아무것도 그리지 않는다. + * - `failed` — 점역이 실패함. 출력 상자가 이미 사유를 보여주므로 목록은 숨긴다. + * - `ok` — 점역 성공. 목록을 그린다. + */ +export interface TraceSnapshot { + status: 'idle' | 'ok' | 'failed' + braille: string + rules: TraceRule[] + /** 규칙이 설명하는 출력 칸 수. */ + attributed: number + /** 전체 출력 칸 수. `attributed`보다 크면 추적되지 않은 칸이 있다는 뜻이다. */ + total: number + /** 입력을 처리한 엔진: `korean` | `english-ueb` | `math`. */ + path: string +} + +export const IDLE_TRACE: TraceSnapshot = { + status: 'idle', + braille: '', + rules: [], + attributed: 0, + total: 0, + path: '', +} + +export const FAILED_TRACE: TraceSnapshot = { + status: 'failed', + braille: '점역할 수 없는 문자가 있습니다.', + rules: [], + attributed: 0, + total: 0, + path: '', +} + +/** + * WASM `TraceResult`를 평범한 JS 값으로 복사하고 WASM 쪽 핸들을 해제한다. + * getter 하나하나가 WASM 메모리를 읽으므로 렌더 중에 다시 만지지 않도록 한 번에 옮긴다. + */ +export function readTrace(result: TraceResult): TraceSnapshot { + const spans = result.rules + const snapshot: TraceSnapshot = { + status: 'ok', + braille: result.braille, + attributed: result.attributed, + total: result.total, + path: result.path, + rules: spans.map((span) => ({ + section: span.section, + name: span.name, + description: span.description, + kind: span.kind, + start: span.start, + end: span.end, + braille: span.braille, + })), + } + for (const span of spans) span.free() + result.free() + return snapshot +} + +/** + * 항 번호 표기. `-`는 규정 항이 없는 구조 출력이고 `?`는 항 번호를 아직 + * 선언하지 않은 규칙이다. 둘 다 `제N항`으로 꾸며내지 않는다. + * + * 영어는 한국 점자 규정이 아니라 UEB 규정을 따르므로 `제N항`이 아닌 `§N` 표기를 + * 쓴다. 수학은 같은 규정 안의 별도 장이라 한글 제N항과 번호가 겹치므로 `수학`을 + * 붙여 구분한다. 번호 체계가 다른 규정을 같은 꼴로 적으면 출처를 잘못 읽게 된다. + */ +function sectionLabel(section: string, kind: string): string | null { + if (section === '-') return null + if (section === '?') return '규정 미표기' + if (kind === 'english-ueb') return `§${section}` + return kind === 'math' ? `수학 제${section}항` : `제${section}항` +} + +/** 반열린 구간 `[start, end)`를 1부터 세는 사람 기준 표기로 옮긴다. */ +function rangeLabel(start: number, end: number): string { + if (end - start <= 1) return `${start + 1}번째 칸` + return `${start + 1}–${end}번째 칸` +} + +/** 빈 칸(U+2800)도 눈에 보이도록 칸 하나를 칩으로 그린다. */ +function BrailleCells({ braille }: { braille: string }) { + return ( + + {Array.from(braille).map((cell, index) => ( + + {cell} + + ))} + + ) +} + +function RuleRow({ rule }: { rule: TraceRule }) { + const section = sectionLabel(rule.section, rule.kind) + + return ( + + + {section ? ( + + {section} + + ) : null} + + + + {rule.name} + + + {rule.description} · {KIND_LABEL[rule.kind] ?? rule.kind} + + + + + + + {rangeLabel(rule.start, rule.end)} + + + ) +} + +/** 점역 결과를 만들어 낸 규칙 목록. 추적은 아직 부분적이라 덮인 범위를 함께 밝힌다. */ +export function RuleTrace({ trace }: { trace: TraceSnapshot }) { + if (trace.status !== 'ok') return null + + const isPartial = trace.attributed < trace.total + const visibleRules = trace.rules.slice(0, MAX_VISIBLE_RULES) + const hiddenCount = trace.rules.length - visibleRules.length + const emptyNotice = + trace.rules.length > 0 + ? null + : (NO_RULE_NOTICE[trace.path] ?? + '이 입력에 대해 기록된 규칙이 아직 없습니다.') + + return ( + + + + 적용 규칙 + + {trace.total > 0 ? ( + + {trace.attributed}/{trace.total}칸 추적됨 + + ) : null} + + + {isPartial ? ( + + 출력 {trace.total}칸 가운데 {trace.attributed}칸만 규칙으로 설명됩니다. + 나머지 {trace.total - trace.attributed}칸은 아직 규칙 추적이 붙지 않은 + 부분입니다. + + ) : null} + {emptyNotice ? ( + + {emptyNotice} + + ) : ( + + {visibleRules.map((rule, index) => ( + + ))} + + )} + {hiddenCount > 0 ? ( + + 규칙 {hiddenCount}개는 목록에 표시하지 않았습니다. + + ) : null} + + ) +} diff --git a/apps/landing/src/app/Trans.tsx b/apps/landing/src/app/Trans.tsx index f02474ef..eea6e844 100644 --- a/apps/landing/src/app/Trans.tsx +++ b/apps/landing/src/app/Trans.tsx @@ -1,28 +1,41 @@ 'use client' import { VStack } from '@devup-ui/react' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { DemoArrow } from './DemoArrow' import { DemoHeading } from './DemoHeading' +import { + FAILED_TRACE, + IDLE_TRACE, + readTrace, + RuleTrace, + type TraceSnapshot, +} from './RuleTrace' import { TransInput } from './TransInput' +type Translate = (input: string) => TraceSnapshot + +const idleTranslate: Translate = () => IDLE_TRACE + export function Trans() { const [input, setInput] = useState('') - const [translateToUnicode, setTranslateToUnicode] = useState< - (input: string) => string - >(() => () => '') + const [translate, setTranslate] = useState(() => idleTranslate) useEffect(() => { import('braillify').then((mod) => { - setTranslateToUnicode(() => (input: string) => { + setTranslate(() => (text: string) => { + if (text.length === 0) return IDLE_TRACE try { - return mod.translateToUnicode(input) + return readTrace(mod.translateToUnicodeWithTrace(text)) } catch (e) { console.error(e) - return '점역할 수 없는 문자가 있습니다.' + return FAILED_TRACE } }) }) - }, [input]) + }, []) + + // 한 번의 점역으로 점자 출력과 규칙 목록을 모두 얻는다. + const trace = useMemo(() => translate(input), [translate, input]) const [inputFocused, setInputFocused] = useState(false) const [translationFocused, setTranslationFocused] = useState(false) @@ -60,9 +73,10 @@ export function Trans() { focusPlaceholder="⠕⠈⠥⠄⠝⠀⠨⠎⠢⠱⠁⠚⠂⠀⠉⠗⠬⠶⠮⠀⠕⠃⠐⠱⠁⠚⠗⠨⠍⠠⠝⠬⠖" isFocused={translationFocused} readOnly - value={translateToUnicode(input)} + value={trace.braille} /> + ) } diff --git a/libs/braillify/src/encoder.rs b/libs/braillify/src/encoder.rs index 81fe77ad..09c136ed 100644 --- a/libs/braillify/src/encoder.rs +++ b/libs/braillify/src/encoder.rs @@ -1,8 +1,10 @@ use std::borrow::Cow; +use crate::korean_char::JamoSpans; use crate::rules; use crate::rules::context::EncodingMode; use crate::rules::token::{Token, WordMeta, WordToken}; +use crate::rules::trace::{TokenOrigins, TracePath, TraceSink}; pub struct Encoder { pub(crate) is_english: bool, @@ -219,14 +221,28 @@ impl Encoder { self.math_mode_active = active; } - fn encode_via_ir(&mut self, text: &str, result: &mut Vec) -> Result<(), String> { - self.encode_via_ir_with_transform(text, result, |_, _| Ok(())) + pub(crate) fn char_rule_registry(&mut self) -> Vec<&'static rules::RuleMeta> { + self.rule_engine.registry() + } + + pub(crate) fn token_rule_registry(&mut self) -> Vec<&'static rules::RuleMeta> { + self.token_engine.registry() + } + + fn encode_via_ir( + &mut self, + text: &str, + result: &mut Vec, + trace: Option>, + ) -> Result<(), String> { + self.encode_via_ir_with_transform(text, result, trace, |_, _| Ok(())) } fn encode_via_ir_with_transform( &mut self, text: &str, result: &mut Vec, + trace: Option>, transform: F, ) -> Result<(), String> where @@ -235,6 +251,7 @@ impl Encoder { let mut ir = rules::token::DocumentIR::parse(text, self.english_indicator); ir.state.matrix_context_active = self.matrix_context_active; ir.state.math_mode_active = self.math_mode_active; + ir.state.jamo_spans = trace.is_some().then(Box::::default); if let Some(mode) = self.default_mode && mode != ir.state.current_mode() @@ -254,7 +271,14 @@ impl Encoder { } let state_before_token_rules = ir.state.clone(); - self.token_engine.apply_all(&mut ir.tokens, &mut ir.state)?; + if trace.is_some() { + rules::math::begin_collection(); + } + let mut origins = trace + .is_some() + .then(|| TokenOrigins::seeded(ir.tokens.len())); + self.token_engine + .apply_all_tracked(&mut ir.tokens, &mut ir.state, origins.as_mut())?; let mode_stack_after_token_rules = ir.state.mode_stack.clone(); // 제39항 영-한 wrap 활성화 신호는 token 단계의 결정이며 emit 단계에서도 // 유효해야 한다. mode_stack과 함께 보존한다. @@ -266,8 +290,15 @@ impl Encoder { ir.state.english_dominant_no_indicator = no_indicator_after_token_rules; transform(text, &mut ir.tokens)?; - let output = rules::emit::emit(&mut ir, &mut self.rule_engine)?; - result.extend(output); + // `transform` injects formatting tokens without origin tracking, so the + // side table no longer lines up with the stream and must be dropped. + if origins.as_ref().is_some_and(|o| o.len() != ir.tokens.len()) { + origins = None; + } + + let output = rules::emit::emit(&mut ir, &mut self.rule_engine, trace, origins.as_ref()); + rules::math::end_collection(); + result.extend(output?); self.is_english = ir.state.is_english; self.triple_big_english = ir.state.triple_big_english; @@ -278,6 +309,15 @@ impl Encoder { } pub fn encode(&mut self, text: &str, result: &mut Vec) -> Result<(), String> { + self.encode_traced(text, result, None) + } + + pub(crate) fn encode_traced( + &mut self, + text: &str, + result: &mut Vec, + mut trace: Option>, + ) -> Result<(), String> { // UEB Grade-2 path: pure-English input (no Korean, UEB-eligible, no // explicit mode) is encoded by the unified English engine. It returns // `Some` only when it fully handles the input; otherwise we fall through @@ -298,9 +338,12 @@ impl Encoder { && let Some(bytes) = crate::rules::english_ueb::try_encode(text) { result.extend(bytes); + if let Some(sink) = trace.as_mut() { + sink.trace.set_path(TracePath::EnglishUeb); + } return Ok(()); } - self.encode_via_ir(text, result) + self.encode_via_ir(text, result, trace) } pub fn encode_with_formatting( @@ -313,7 +356,7 @@ impl Encoder { return self.encode(text, result); } - self.encode_via_ir_with_transform(text, result, |source, tokens| { + self.encode_via_ir_with_transform(text, result, None, |source, tokens| { inject_formatting_tokens(source, spans, tokens) }) } diff --git a/libs/braillify/src/korean_char.rs b/libs/braillify/src/korean_char.rs index 65ee8406..4b018bbe 100644 --- a/libs/braillify/src/korean_char.rs +++ b/libs/braillify/src/korean_char.rs @@ -3,26 +3,88 @@ use crate::{ char_struct::KoreanChar, jauem::{choseong::encode_choseong, jongseong::encode_jongseong}, moeum::jungsong::encode_jungsong, + rules::trace::JamoRule, split::split_korean_jauem, utils::build_char, }; +/// Where syllable composition reports the article behind each stretch of cells. +/// +/// Implemented twice so the choice is made at compile time: [`NoSpans`] makes +/// every report vanish, leaving the untraced encoder byte-identical to one with +/// no tracing code at all. A runtime flag instead leaves a branch per jamo on the +/// hottest path in the library, which measured 1-5% slower. +trait SpanSink { + fn report(&mut self, rule: JamoRule, start: usize, end: usize); +} + +struct NoSpans; + +impl SpanSink for NoSpans { + #[inline(always)] + fn report(&mut self, _rule: JamoRule, _start: usize, _end: usize) {} +} + +/// The article behind each stretch of one syllable's cells. +/// +/// Composition takes a different branch depending on which abbreviations exist +/// for the syllable, and each branch cites a different article. Collecting the +/// spans is what lets a caller report 제3항 for a 받침 instead of one composite +/// entry for the whole character. +#[derive(Debug, Default, Clone)] +pub struct JamoSpans { + spans: Vec<(JamoRule, core::ops::Range)>, +} + +impl JamoSpans { + pub fn drain(&mut self) -> impl Iterator)> + '_ { + self.spans.drain(..) + } +} + +impl SpanSink for JamoSpans { + fn report(&mut self, rule: JamoRule, start: usize, end: usize) { + if start < end { + self.spans.push((rule, start as u32..end as u32)); + } + } +} + /// 합성 종성(예: ㄳ→ㄱ+ㅅ) 두 번째 자모가 있으면 인코딩 후 result에 추가한다. -fn extend_compound_jongseong(jong1: Option, result: &mut Vec) -> Result<(), String> { +fn extend_compound_jongseong( + jong1: Option, + result: &mut Vec, + spans: &mut S, +) -> Result<(), String> { if let Some(code) = jong1 { + let start = result.len(); let bytes = encode_jongseong(code)?; result.extend(bytes); + spans.report(JamoRule::Jongseong, start, result.len()); } Ok(()) } pub fn encode_korean_char(korean: &KoreanChar) -> Result, String> { + encode_syllable(korean, &mut NoSpans) +} + +pub fn encode_korean_char_with_spans( + korean: &KoreanChar, + spans: &mut JamoSpans, +) -> Result, String> { + encode_syllable(korean, spans) +} + +fn encode_syllable(korean: &KoreanChar, spans: &mut S) -> Result, String> { let mut result = Vec::new(); let (cho0, cho1) = split_korean_jauem(korean.cho)?; if cho1.is_some() { // 쌍자음이라는 뜻, 초성은 반드시 쌍자음이다. result.push(32); + spans.report(JamoRule::DoubleChoseong, result.len() - 1, result.len()); } + let vowel = JamoRule::for_vowel(korean.jung); if let Some(jong) = korean.jong { let (jong0, jong1) = split_korean_jauem(jong)?; if let Ok(code) = @@ -30,40 +92,62 @@ pub fn encode_korean_char(korean: &KoreanChar) -> Result, String> { { // 초성 자체를 결합 if cho0 != 'ㅇ' { + let start = result.len(); result.push(encode_choseong(cho0)?); + spans.report(JamoRule::Choseong, start, result.len()); } + let start = result.len(); result.extend(code); - extend_compound_jongseong(jong1, &mut result)?; + spans.report(vowel, start, result.len()); + extend_compound_jongseong(jong1, &mut result, spans)?; } else if let Ok(code) = char_shortcut::encode_char_shortcut(build_char(cho0, korean.jung, Some(jong0))) { + let start = result.len(); result.extend(code); - extend_compound_jongseong(jong1, &mut result)?; + spans.report(JamoRule::Shortcut, start, result.len()); + extend_compound_jongseong(jong1, &mut result, spans)?; } else if let Ok(code) = char_shortcut::encode_char_shortcut(build_char(cho0, korean.jung, None)) { + let start = result.len(); result.extend(code); + spans.report(JamoRule::Shortcut, start, result.len()); // 종성 자체를 결합 + let start = result.len(); result.extend(encode_jongseong(jong)?); + spans.report(JamoRule::Jongseong, start, result.len()); } else { // shortcut 이 없으므로 초성, 중성, 종성 모두 결합 if cho0 != 'ㅇ' { + let start = result.len(); result.push(encode_choseong(cho0)?); + spans.report(JamoRule::Choseong, start, result.len()); } + let start = result.len(); result.extend(encode_jungsong(korean.jung)?); + spans.report(vowel, start, result.len()); + let start = result.len(); result.extend(encode_jongseong(jong)?); + spans.report(JamoRule::Jongseong, start, result.len()); } } else if let Ok(code) = char_shortcut::encode_char_shortcut(build_char(cho0, korean.jung, None)) { + let start = result.len(); result.extend(code); + spans.report(JamoRule::Shortcut, start, result.len()); } else { // shortcut 이 없으므로 초성 중성, 모두 결합 if cho0 != 'ㅇ' { + let start = result.len(); result.push(encode_choseong(cho0)?); + spans.report(JamoRule::Choseong, start, result.len()); } + let start = result.len(); result.extend(encode_jungsong(korean.jung)?); + spans.report(vowel, start, result.len()); } Ok(result) diff --git a/libs/braillify/src/lib.rs b/libs/braillify/src/lib.rs index 54e8fe93..e50d8f29 100644 --- a/libs/braillify/src/lib.rs +++ b/libs/braillify/src/lib.rs @@ -182,6 +182,11 @@ mod test_helpers { } pub use encoder::Encoder; +use rules::trace::TraceSink; +pub use rules::trace::{ + EmitterRule, RuleId, RuleKind, RuleOutcome, Trace, TraceEvent, TracePath, registered_rules, + rule_meta, +}; thread_local! { static ENCODER_CACHE: RefCell> = const { RefCell::new(None) }; @@ -1079,6 +1084,33 @@ fn is_isolated_roman_section(text: &str) -> bool { /// Encode text to braille with explicit options. pub fn encode_with_options(text: &str, options: &EncodeOptions) -> Result, String> { + encode_with_options_traced(text, options, None) +} + +/// Encode `text` and report which rules produced which output cells. +/// +/// Read [`Trace::path`] before drawing conclusions from partial attribution: +/// each engine reports only the rule families it instruments. +pub fn encode_with_trace(text: &str) -> Result<(Vec, Trace), String> { + encode_with_options_and_trace(text, &EncodeOptions::default()) +} + +/// [`encode_with_trace`] with an explicit encoding mode. +pub fn encode_with_options_and_trace( + text: &str, + options: &EncodeOptions, +) -> Result<(Vec, Trace), String> { + let mut trace = Trace::default(); + let cells = encode_with_options_traced(text, options, Some(&mut trace))?; + trace.set_output_len(cells.len() as u32); + Ok((cells, trace)) +} + +fn encode_with_options_traced( + text: &str, + options: &EncodeOptions, + mut trace: Option<&mut Trace>, +) -> Result, String> { use crate::rules::context::EncodingMode; // PDF 수학 — Mathematical Alphanumeric 변형(italic/bold/script 등)을 ASCII로 @@ -1102,8 +1134,14 @@ pub fn encode_with_options(text: &str, options: &EncodeOptions) -> Result Result Result { + mark_trace_path(&mut trace, TracePath::MathExpression); + return Ok(bytes); + } + Err(_) => { + if let (Some(sink), Some(mark)) = (trace.as_deref_mut(), mark) { + sink.rollback_to(mark); + } + } } } @@ -1365,7 +1424,7 @@ pub fn encode_with_options(text: &str, options: &EncodeOptions) -> Result Result, + untraced: impl FnOnce(&str) -> Option>, + traced: impl FnOnce(&str) -> Option<(Vec, Vec)>, +) -> Option> { + let Some(sink) = trace.as_deref_mut() else { + return untraced(text); + }; + let (bytes, spans) = traced(text)?; + for (rule, output) in spans { + sink.push(TraceEvent { + rule, + outcome: RuleOutcome::Consumed, + token_index: 0, + word_chars: 0..0, + output, + }); + } + Some(bytes) +} + +fn mark_trace_path(trace: &mut Option<&mut Trace>, path: TracePath) { + if let Some(sink) = trace.as_deref_mut() { + sink.set_path(path); + } +} + /// Encode text with explicit formatting spans. pub fn encode_with_formatting(text: &str, spans: &[FormattingSpan]) -> Result, String> { if spans.is_empty() { @@ -1427,6 +1522,334 @@ pub fn encode_to_braille_font(text: &str) -> Result { .collect::()) } +#[cfg(test)] +mod trace_tests { + use super::*; + use crate::rules::context::EncodingMode; + + fn korean_mode() -> EncodeOptions { + EncodeOptions { + default_mode: Some(EncodingMode::Korean), + } + } + + #[rstest::rstest] + #[case::korean_syllables("안녕")] + #[case::korean_abbreviation("그래서")] + #[case::english_prose("hello")] + #[case::latex_fraction("$\\frac{3}{4}$")] + #[case::mixed_sentence("가나다 라마")] + #[case::roman_inside_korean("가 ABC")] + #[case::digits("2024년")] + fn tracing_leaves_the_encoded_cells_unchanged(#[case] input: &str) { + let plain = encode(input).expect("input must encode"); + let (traced, _) = encode_with_trace(input).expect("input must encode"); + + assert_eq!(plain, traced); + } + + #[rstest::rstest] + #[case::korean("안녕", TracePath::KoreanRules)] + #[case::mixed("가나다 라마", TracePath::KoreanRules)] + #[case::english("hello", TracePath::EnglishUeb)] + fn path_names_the_engine_that_ran(#[case] input: &str, #[case] expected: TracePath) { + let (_, trace) = encode_with_trace(input).expect("input must encode"); + + assert_eq!(trace.path(), expected); + } + + #[test] + fn korean_syllables_attribute_every_cell_to_a_registered_rule() { + let (cells, trace) = encode_with_trace("안녕").expect("input must encode"); + + assert_eq!(trace.attributed_cells(), cells.len() as u32); + assert_eq!(trace.unattributed_cells(), 0); + assert!( + trace.events().iter().all(|e| e.rule.meta().is_some()), + "every recorded id resolves against the registry" + ); + } + + /// 약자 abbreviation is a token-level rewrite whose cells never reach the + /// character engine, so it is attributed through the token-origin side table + /// rather than by the character rule loop. + #[test] + fn token_rule_output_is_attributed_to_the_token_engine() { + let (cells, trace) = encode_with_trace("그래서").expect("input must encode"); + + assert!(!cells.is_empty(), "the abbreviation still encodes"); + assert_eq!(trace.attributed_cells(), cells.len() as u32); + assert!( + trace + .events() + .iter() + .all(|e| e.rule.kind() == Some(RuleKind::Token)), + "abbreviation cells come from a token rule: {:?}", + trace.events() + ); + } + + /// UEB picks contractions by a cell-minimising search, so only the winning + /// path may be credited. Every recorded range must therefore land inside the + /// output and name a UEB rule. + #[rstest::rstest] + #[case::uncontracted("hello")] + #[case::sentence("the child was here")] + #[case::accented("naive")] + fn english_attributes_only_the_selected_contraction_path(#[case] input: &str) { + let (cells, trace) = encode_with_trace(input).expect("input must encode"); + + assert_eq!(trace.path(), TracePath::EnglishUeb); + assert!(trace.attributed_cells() > 0, "UEB now names its rules"); + assert!( + trace.events().iter().all(|event| { + // Inter-word blanks belong to the emitter, not to a UEB rule. + matches!( + event.rule.kind(), + Some(RuleKind::EnglishUeb | RuleKind::Emitter) + ) && event.output.start < event.output.end + && event.output.end as usize <= cells.len() + }), + "{:?}", + trace.events() + ); + } + + /// A whole-word sign is a table lookup rather than a contraction search, so + /// it is recorded where the table is consulted. Its section is known exactly, + /// so the cells are named rather than left unexplained. + #[rstest::rstest] + #[case::alphabetic_wordsign("knowledge", "10.1")] + #[case::shortform("about", "10.9")] + #[case::lower_wordsign("enough", "10.5")] + fn english_wordsigns_name_their_section(#[case] input: &str, #[case] section: &str) { + let (cells, trace) = encode_with_trace(input).expect("input must encode"); + + assert_eq!(trace.attributed_cells(), cells.len() as u32); + let sections: Vec<&str> = trace + .events() + .iter() + .filter_map(|event| event.rule.meta().map(|meta| meta.section)) + .collect(); + assert!( + sections.contains(§ion), + "expected §{section} among {sections:?}" + ); + } + + #[rstest::rstest] + #[case::korean("가나다 라마")] + #[case::korean_prose("나는 학교에 간다")] + #[case::english("the child was here")] + #[case::mixed_numbers("2024년 제12항")] + #[case::math_plain("3+4=7")] + #[case::math_variables("$x^2+y^2=z^2$")] + #[case::math_function("$\\sin x$")] + #[case::latex_fraction("$\\frac{3}{4}$")] + // 제35항 numeric bridge resuming into a lowercase a-j letter: UEB 6.5.2 makes + // the emitter write a continuation cell there, and it must name itself. + #[case::roman_number_bridge_into_low_letter("가나 (1c) 다라")] + fn every_output_cell_is_accounted_for(#[case] input: &str) { + let (cells, trace) = encode_with_trace(input).expect("input must encode"); + + assert_eq!( + trace.attributed_cells(), + cells.len() as u32, + "unattributed cells in {input:?}: {:?}", + trace.events() + ); + } + + /// The two paths that still leave cells unexplained, pinned to their exact + /// numbers so the gap cannot widen unnoticed and any narrowing is visible. + /// + /// Both are mode indicators rather than content: the Roman indicator the + /// emitter writes ahead of a Roman run, and the numeric/symbol cells the UEB + /// engine writes outside its contraction search. + #[rstest::rstest] + #[case::roman_in_korean("가영이는 Los Angeles에 산다")] + #[case::numbers_and_symbols("50% & 3 items")] + #[case::measurement("3kg 5%")] + #[case::acronym_with_digit("MP3 player")] + fn indicator_and_numeric_cells_are_accounted_for(#[case] input: &str) { + let (cells, trace) = encode_with_trace(input).expect("input must encode"); + + assert_eq!( + trace.attributed_cells(), + cells.len() as u32, + "unattributed cells in {input:?}: {:?}", + trace.events() + ); + } + + /// The capitals and grade-1 indicators are written straight into the output + /// by the word encoder, while UEB attribution places whole *attempts* of the + /// contraction search — so an indicator belongs to no attempt and stays + /// unexplained. A Korean document never reaches this: the whole 467k-sentence + /// corpus leaves no cell unexplained, and only one sentence in it takes the + /// UEB path at all. Pinned to the exact counts so the gap cannot widen while + /// unnoticed, and so closing it shows up here as a failure to update. + #[rstest::rstest] + #[case::capital_then_digits("A1", 1)] + #[case::capital_digits_and_decimal("Q50 2.2d", 3)] + fn the_ueb_only_path_still_leaves_its_indicators_unexplained( + #[case] input: &str, + #[case] expected: u32, + ) { + let (_, trace) = encode_with_trace(input).expect("input must encode"); + + assert_eq!(trace.path(), TracePath::EnglishUeb); + assert_eq!(trace.unattributed_cells(), expected); + } + + /// Every rule must name a cell range that is really its own, so a cell may + /// never be claimed by two rules at once. + #[rstest::rstest] + #[case::korean("안녕하세요")] + #[case::mixed("가영이는 Los Angeles에 산다")] + #[case::measurement("3kg 5%")] + #[case::english("the child was here")] + #[case::math("3+4=7")] + fn no_cell_is_claimed_twice(#[case] input: &str) { + let (cells, trace) = encode_with_trace(input).expect("input must encode"); + + let mut claims = vec![0u32; cells.len()]; + for event in trace.events() { + for cell in event.output.clone() { + claims[cell as usize] += 1; + } + } + + assert!( + claims.iter().all(|count| *count == 1), + "cells claimed {claims:?} times in {input:?}: {:?}", + trace.events() + ); + } + + /// A math expression reaches the emitter as one pre-encoded run, so without + /// the math engine's own spans it would report only the token rule that + /// detected it. + #[rstest::rstest] + #[case::sum("3+4=7", "1")] + #[case::superscript("$x^2$", "18")] + #[case::function("$\\sin x$", "47")] + fn math_expressions_name_their_math_article(#[case] input: &str, #[case] section: &str) { + let (_, trace) = encode_with_trace(input).expect("input must encode"); + + let sections: Vec<&str> = trace + .events() + .iter() + .filter(|event| event.rule.kind() == Some(RuleKind::Math)) + .filter_map(|event| event.rule.meta().map(|meta| meta.section)) + .collect(); + + assert!( + sections.contains(§ion), + "expected 수학 제{section}항 among {sections:?}" + ); + } + + #[rstest::rstest] + #[case::korean("안녕하세요")] + #[case::mixed("가나다 라마 ABC")] + #[case::numbers("제12항 3개")] + fn every_recorded_range_lies_inside_the_output(#[case] input: &str) { + let (cells, trace) = encode_with_trace(input).expect("input must encode"); + + for event in trace.events() { + assert!( + event.output.end as usize <= cells.len(), + "{event:?} runs past {} cells", + cells.len() + ); + assert!(event.output.start <= event.output.end); + } + } + + /// 제37항 inserts the Roman indicator at cell 0 *after* encoding, so every + /// range recorded before that insertion points one cell short unless shifted. + #[test] + fn roman_wrap_shifts_recorded_ranges_onto_the_right_cells() { + let (cells, trace) = + encode_with_options_and_trace("ABC", &korean_mode()).expect("input must encode"); + + assert_eq!(cells.first(), Some(&52), "제37항 로마자표"); + let spans: Vec<&[u8]> = trace + .events() + .iter() + .map(|event| &cells[event.output.start as usize..event.output.end as usize]) + .collect(); + assert!( + spans.contains(&&[1u8, 3, 9][..]), + "one span must render A, B, C; got {spans:?}" + ); + } + + /// The encoder is cached per thread, so a leaked sink would make the second + /// trace of the same input differ from the first. + #[test] + fn trace_does_not_bleed_across_calls_on_the_cached_encoder() { + let (_, before) = encode_with_trace("안녕").expect("input must encode"); + let _ = encode_with_trace("hello").expect("input must encode"); + let (_, after) = encode_with_trace("안녕").expect("input must encode"); + + assert_eq!(before, after); + } + + /// 안 = ㅇ + ㅏ + ㄴ, so its two cells are the 제6항 vowel and the 제3항 받침 + /// rather than one composite entry for the syllable. + #[test] + fn syllable_cells_name_their_own_article() { + let (cells, trace) = encode_with_trace("안녕").expect("input must encode"); + + let article_at = |cell: u32| { + trace + .rules_at_cell(cell) + .first() + .and_then(|rule| rule.meta()) + .map(|meta| (meta.section, meta.name)) + }; + + assert_eq!(article_at(0), Some(("6", "syllable_jungseong"))); + assert_eq!(article_at(1), Some(("3", "syllable_jongseong"))); + assert!(trace.rules_at_cell(cells.len() as u32).is_empty()); + } + + #[rstest::rstest] + #[case::vowel("안녕", "6")] + #[case::final_consonant("안녕", "3")] + #[case::double_initial("깎다", "2")] + #[case::initial_consonant("라", "1")] + fn syllable_composition_cites_jamo_articles(#[case] input: &str, #[case] section: &str) { + let (_, trace) = encode_with_trace(input).expect("input must encode"); + + let sections: Vec<&str> = trace + .events() + .iter() + .filter(|event| event.rule.kind() == Some(RuleKind::Jamo)) + .filter_map(|event| event.rule.meta().map(|meta| meta.section)) + .collect(); + + assert!( + sections.contains(§ion), + "expected 제{section}항 among {sections:?}" + ); + } + + #[test] + fn contributing_rules_lists_each_rule_once() { + let (_, trace) = encode_with_trace("가나다 라마").expect("input must encode"); + let contributing = trace.contributing_rules(); + let mut unique = contributing.clone(); + unique.sort_unstable(); + unique.dedup(); + + assert!(!contributing.is_empty()); + assert_eq!(contributing.len(), unique.len()); + } +} + #[cfg(test)] mod state_bleed_tests { use super::encode; diff --git a/libs/braillify/src/rules/context.rs b/libs/braillify/src/rules/context.rs index 0f26671c..3654f298 100644 --- a/libs/braillify/src/rules/context.rs +++ b/libs/braillify/src/rules/context.rs @@ -115,6 +115,14 @@ pub struct EncoderState { /// 0보다 크면 현재 위치는 paired closing 위치이므로 `’`를 `⠴⠄`로 emit. /// 0이면 standalone apostrophe로 `⠄` 한 셀만 emit. (PDF 제61항) pub unmatched_open_single_quotes: i32, + /// Per-article spans the last Korean syllable produced, handed from + /// `RuleKorean` to the trace recorder in [`super::engine`]. + /// + /// `None` whenever no trace is being collected, which is both the signal to + /// rules that this work is unwanted and the reason the untraced encoder pays + /// only one pointer for the feature — this struct is carried by `&mut` + /// through the per-character loop, so its size is on the hot path. + pub jamo_spans: Option>, } impl EncoderState { @@ -137,6 +145,7 @@ impl EncoderState { matrix_context_active: false, math_mode_active: false, unmatched_open_single_quotes: 0, + jamo_spans: None, } } diff --git a/libs/braillify/src/rules/emit.rs b/libs/braillify/src/rules/emit.rs index 0f2a98ad..ec4229f9 100644 --- a/libs/braillify/src/rules/emit.rs +++ b/libs/braillify/src/rules/emit.rs @@ -1,4 +1,4 @@ -use crate::char_struct::{CharType, KoreanChar}; +use crate::char_struct::{CharType, KoreanChar}; use crate::english_logic; use crate::fraction; use crate::rules::context::{EncoderState, RuleContext}; @@ -6,6 +6,7 @@ use crate::rules::engine::RuleEngine; use crate::rules::korean::rule_29::{ENGLISH_CONTINUATION, ROMAN_INDICATOR, ROMAN_TERMINATOR}; use crate::rules::korean::rule_69::parse_numeric_ascii_unit_prefix; use crate::rules::roman_mode; +use crate::rules::trace::{EmitterRule, RuleId, TokenOrigins, TraceSink}; use crate::rules::traits::Phase; use super::token::{DocumentIR, ModeEvent, SpaceKind, Token, WordToken}; @@ -435,7 +436,12 @@ fn is_math_operator_space_suppression<'a>(tokens: &'a [Token<'a>], space_idx: us false } -pub fn emit(ir: &mut DocumentIR, char_engine: &mut RuleEngine) -> Result, String> { +pub fn emit( + ir: &mut DocumentIR, + char_engine: &mut RuleEngine, + mut trace: Option>, + origins: Option<&TokenOrigins>, +) -> Result, String> { let mut result = Vec::new(); let word_texts = if ir.tokens.len() > 1 { collect_word_texts(&ir.tokens) @@ -463,12 +469,17 @@ pub fn emit(ir: &mut DocumentIR, char_engine: &mut RuleEngine) -> Result &ir.tokens, context, &mut result, + trace.as_mut().map(|sink| sink.at_token(idx)), )?; word_index += 1; } Token::Space(SpaceKind::Regular) => { if !is_math_operator_space_suppression(&ir.tokens, idx) { + let start = result.len(); result.push(0); + record_token_span(&mut trace, origins, idx, &result, start, || { + RuleId::emitter(EmitterRule::WordSpace) + }); } } Token::Mode(event) => { @@ -497,10 +508,15 @@ pub fn emit(ir: &mut DocumentIR, char_engine: &mut RuleEngine) -> Result ir.state.roman_section_is_english_context = roman_section_has_english_phrase_context(&ir.tokens, idx); } + let start = result.len(); enter_roman_before_ueb_prefix(&ir.tokens, idx, event, &mut ir.state, &mut result); emit_mode_event(event, &mut ir.state, &mut result); + record_token_span(&mut trace, origins, idx, &result, start, || { + RuleId::emitter(EmitterRule::UndeclaredTokenOutput) + }); } Token::Fraction(frac) => { + let start = result.len(); if let Some(ref w) = frac.whole { result.extend(fraction::encode_mixed_fraction( w, @@ -514,6 +530,9 @@ pub fn emit(ir: &mut DocumentIR, char_engine: &mut RuleEngine) -> Result )?); } ir.state.is_number = true; + record_token_span(&mut trace, origins, idx, &result, start, || { + RuleId::emitter(EmitterRule::UndeclaredTokenOutput) + }); } Token::PreEncoded(bytes) => { // 제39항 한글 wrap 점형은 영어 모드를 자동으로 휴면(⠸⠷)·재개(⠸⠾)시킨다. @@ -524,7 +543,11 @@ pub fn emit(ir: &mut DocumentIR, char_engine: &mut RuleEngine) -> Result } else if bytes.as_slice() == HANGUL_WRAP_END_BYTES { roman_mode::set_section_open_keeping_number_chain(&mut ir.state, true); } + let start = result.len(); result.extend(bytes); + record_token_span(&mut trace, origins, idx, &result, start, || { + RuleId::emitter(EmitterRule::UndeclaredTokenOutput) + }); } } } @@ -540,6 +563,62 @@ pub fn emit(ir: &mut DocumentIR, char_engine: &mut RuleEngine) -> Result Ok(result) } +/// Attribute `start..end` to the rule that produced token `idx`. +/// +/// A math expression arrives here as one pre-encoded run, so its own rules would +/// be hidden behind the token rule that detected it. When the run is one the math +/// engine produced, its per-rule spans replace the single token-rule span. +/// Close the Roman section and attribute whatever terminator it wrote. +/// +/// The emitter decides section boundaries from the token stream, so these cells +/// never pass through a character rule and would otherwise be the one part of a +/// Korean/Roman sentence left unexplained. +fn close_roman_section_traced( + result: &mut Vec, + state: &mut EncoderState, + all_tokens: &[Token<'_>], + token_index: usize, + trace: &mut Option>, +) { + let start = result.len(); + close_roman_section(result, state, all_tokens, token_index); + if let Some(sink) = trace.as_mut() + && result.len() > start + { + sink.record_span( + RuleId::emitter(EmitterRule::RomanSectionMarker), + token_index, + start..result.len(), + ); + } +} + +fn record_token_span( + trace: &mut Option>, + origins: Option<&TokenOrigins>, + idx: usize, + result: &[u8], + start: usize, + fallback: impl FnOnce() -> RuleId, +) { + let Some(sink) = trace.as_mut() else { + return; + }; + let end = result.len(); + if start == end { + return; + } + if let Some(spans) = crate::rules::math::spans_for(&result[start..end]) { + for (rule, offset, len) in spans { + let span_start = start + offset as usize; + sink.record_span(rule, idx, span_start..span_start + len as usize); + } + return; + } + let rule = origins.and_then(|o| o.get(idx)).unwrap_or_else(fallback); + sink.record_span(rule, idx, start..end); +} + fn collect_word_texts<'tokens, 'source>(tokens: &'tokens [Token<'source>]) -> Vec<&'tokens str> { let mut word_texts = Vec::with_capacity(tokens.len().div_ceil(2)); @@ -891,6 +970,7 @@ fn apply_core_encoding_rules( remaining_words: &[&str], prev_word: &str, result: &mut Vec, + trace: Option>, ) -> Result { let mut ctx = RuleContext { word_chars, @@ -906,7 +986,7 @@ fn apply_core_encoding_rules( state, result, }; - engine.apply_phase(Phase::CoreEncoding, &mut ctx) + engine.apply_phase(Phase::CoreEncoding, &mut ctx, trace) } #[allow(clippy::too_many_arguments)] @@ -924,6 +1004,7 @@ fn apply_inter_character_rules( remaining_words: &[&str], prev_word: &str, result: &mut Vec, + trace: Option>, ) -> Result { let mut ctx = RuleContext { word_chars, @@ -939,9 +1020,10 @@ fn apply_inter_character_rules( state, result, }; - engine.apply_phase(Phase::InterCharacter, &mut ctx) + engine.apply_phase(Phase::InterCharacter, &mut ctx, trace) } +#[allow(clippy::too_many_arguments)] fn emit_word( word: &WordToken, token_index: usize, @@ -950,6 +1032,7 @@ fn emit_word( all_tokens: &[Token], context: WordContext<'_>, result: &mut Vec, + mut trace: Option>, ) -> Result<(), String> { let prev_word = context.prev_word; let remaining_words = context.remaining_words; @@ -982,7 +1065,15 @@ fn emit_word( } let mut encoded = crate::encode(&numeric)?; encoded.extend(unit); + let start = result.len(); result.extend(encoded); + if let Some(sink) = trace.as_mut() { + sink.record_span( + crate::rules::trace::korean_rule_id("measurement_symbols"), + token_index, + start..result.len(), + ); + } roman_mode::set_section_open_keeping_number_chain(state, continues_roman_section); return Ok(()); } @@ -1010,7 +1101,17 @@ fn emit_word( } // English entry (제28/35/39항) — 로마자표/연속표 emit + 영어 모드 전환. + let roman_open_start = result.len(); roman_mode::enter_english_if_starting(state, word_chars, has_ascii_alphabetic, result); + if let Some(sink) = trace.as_mut() + && result.len() > roman_open_start + { + sink.record_span( + RuleId::emitter(EmitterRule::RomanSectionMarker), + token_index, + roman_open_start..result.len(), + ); + } let first_ascii_index = word_chars.iter().position(|c| c.is_ascii_alphabetic()); let ascii_starts_at_beginning = matches!(first_ascii_index, Some(0)); @@ -1087,7 +1188,13 @@ fn emit_word( } else if english_logic::should_force_terminator_before_symbol(*sym) || !english_logic::should_skip_terminator_for_symbol(*sym) { - close_roman_section(result, state, all_tokens, token_index); + close_roman_section_traced( + result, + state, + all_tokens, + token_index, + &mut trace, + ); } else { roman_mode::exit_english( state, @@ -1096,7 +1203,13 @@ fn emit_word( } } _ => { - close_roman_section(result, state, all_tokens, token_index); + close_roman_section_traced( + result, + state, + all_tokens, + token_index, + &mut trace, + ); } } } @@ -1111,7 +1224,15 @@ fn emit_word( // a capital indicator or a lowercase k-z cell is sufficient // for every other Roman letter class. if matches!(*c, 'a'..='j') { + let bridge_start = result.len(); result.push(crate::rules::korean::rule_29::ENGLISH_CONTINUATION); + if let Some(sink) = trace.as_mut() { + sink.record_span( + RuleId::emitter(EmitterRule::RomanSectionMarker), + token_index, + bridge_start..result.len(), + ); + } } roman_mode::resume_english_from_roman_number_chain(state); } @@ -1188,6 +1309,7 @@ fn emit_word( remaining_words, prev_word, result, + trace.as_mut().map(TraceSink::reborrow), )?; is_number = state.is_number; is_big_english = state.is_big_english; @@ -1217,6 +1339,7 @@ fn emit_word( remaining_words, prev_word, result, + trace.as_mut().map(TraceSink::reborrow), )?; is_number = state.is_number; is_big_english = state.is_big_english; @@ -1242,7 +1365,7 @@ fn emit_word( // 영어 주도 문서: 영어 단어 사이의 종료표 ⠲ 모두 생략하고 영어 모드를 유지. } else if state.english_indicator && state.is_english { if remaining_words.is_empty() { - close_roman_section(result, state, all_tokens, token_index); + close_roman_section_traced(result, state, all_tokens, token_index, &mut trace); } else if let Some(next_word) = remaining_words.first() { let ascii_letters = next_word .chars() @@ -1284,7 +1407,13 @@ fn emit_word( // print has whitespace first (`Poison (모래성)`), // Rule 29 closes the Roman run before that space. if next_word_is_separated && !separated_continuation { - close_roman_section(result, state, all_tokens, token_index); + close_roman_section_traced( + result, + state, + all_tokens, + token_index, + &mut trace, + ); } else if separated_continuation && sym == '&' { // A standalone ampersand joining Roman words is // itself part of the current Roman section. @@ -1297,7 +1426,13 @@ fn emit_word( } else if english_logic::should_force_terminator_before_symbol(sym) || !english_logic::should_skip_terminator_for_symbol(sym) { - close_roman_section(result, state, all_tokens, token_index); + close_roman_section_traced( + result, + state, + all_tokens, + token_index, + &mut trace, + ); } else { roman_mode::exit_english( state, @@ -1306,11 +1441,17 @@ fn emit_word( } } _ => { - close_roman_section(result, state, all_tokens, token_index); + close_roman_section_traced( + result, + state, + all_tokens, + token_index, + &mut trace, + ); } } } else { - close_roman_section(result, state, all_tokens, token_index); + close_roman_section_traced(result, state, all_tokens, token_index, &mut trace); } } } @@ -1424,7 +1565,7 @@ mod tests { .apply_all(&mut ir.tokens, &mut ir.state) .unwrap(); ir.state = state_before_token_rules; - let emitted = emit(&mut ir, &mut engine).unwrap(); + let emitted = emit(&mut ir, &mut engine, None, None).unwrap(); let expected = encode(text).unwrap(); assert_eq!( emitted, expected, @@ -1664,7 +1805,8 @@ mod tests { let mut ir = DocumentIR::parse("ABC/한글", true); let mut engine = make_char_engine(); - let output = emit(&mut ir, &mut engine).expect("mixed Roman/Korean word must encode"); + let output = + emit(&mut ir, &mut engine, None, None).expect("mixed Roman/Korean word must encode"); assert!(output.contains(&crate::unicode::decode_unicode('⠲'))); assert!(!ir.state.is_english); @@ -1693,6 +1835,7 @@ mod tests { remaining_words: &remaining_words, }, &mut result, + None, ) .expect("Roman word must encode"); @@ -1748,7 +1891,7 @@ mod tests { state: EncoderState::new(false), }; let mut engine = make_char_engine(); - let out = emit(&mut ir, &mut engine).unwrap(); + let out = emit(&mut ir, &mut engine, None, None).unwrap(); assert_eq!(out, vec![52, 48, 32, 32, 32, 32, 32, 32, 4, 48]); } @@ -1770,7 +1913,7 @@ mod tests { }; let mut engine = make_char_engine(); - let out = emit(&mut ir, &mut engine).unwrap(); + let out = emit(&mut ir, &mut engine, None, None).unwrap(); assert!(out.starts_with(&[52, 32, 32])); } @@ -1792,7 +1935,7 @@ mod tests { }; let mut engine = make_char_engine(); - let out = emit(&mut ir, &mut engine).unwrap(); + let out = emit(&mut ir, &mut engine, None, None).unwrap(); assert!(out.starts_with(&[52, 32, 32])); assert_eq!(out.iter().filter(|byte| **byte == 52).count(), 1); @@ -1827,7 +1970,7 @@ mod tests { }; let mut engine = make_char_engine(); - let out = emit(&mut ir, &mut engine).unwrap(); + let out = emit(&mut ir, &mut engine, None, None).unwrap(); assert_eq!(out.iter().filter(|byte| **byte == 52).count(), 1); } @@ -2057,7 +2200,7 @@ mod tests { state: EncoderState::new(false), }; let mut engine = make_char_engine(); - let out = emit(&mut ir, &mut engine).unwrap(); + let out = emit(&mut ir, &mut engine, None, None).unwrap(); let mut expected = fraction::encode_fraction("1", "2").unwrap(); expected.push(0); @@ -2120,7 +2263,7 @@ mod tests { let mut ir = DocumentIR::parse("", false); ir.state.triple_big_english = true; let mut engine = RuleEngine::new(); - let result = emit(&mut ir, &mut engine).unwrap(); + let result = emit(&mut ir, &mut engine, None, None).unwrap(); assert_eq!( result, vec![32, 4], diff --git a/libs/braillify/src/rules/engine.rs b/libs/braillify/src/rules/engine.rs index 53567614..765cec19 100644 --- a/libs/braillify/src/rules/engine.rs +++ b/libs/braillify/src/rules/engine.rs @@ -5,7 +5,9 @@ use std::collections::HashSet; +use super::RuleMeta; use super::context::RuleContext; +use super::trace::{RuleId, RuleOutcome, TraceEvent, TraceSink}; use super::traits::{BrailleRule, Phase, RuleResult}; /// The rule engine — holds all registered rules and applies them. @@ -45,6 +47,12 @@ impl RuleEngine { self.sorted = false; } + /// Metadata of every registered rule, in [`RuleId`] order. + pub(crate) fn registry(&mut self) -> Vec<&'static RuleMeta> { + self.ensure_sorted(); + self.rules.iter().map(|rule| rule.meta()).collect() + } + /// Disable a rule by its section ID (e.g., "11" to disable 제11항). #[cfg(test)] pub fn disable(&mut self, section: &str) { @@ -79,7 +87,7 @@ impl RuleEngine { /// List all registered rule metadata (for introspection/debugging). #[cfg(test)] - pub fn list_rules(&self) -> Vec<&super::RuleMeta> { + pub fn list_rules(&self) -> Vec<&'static RuleMeta> { self.rules.iter().map(|r| r.meta()).collect() } @@ -123,10 +131,16 @@ impl RuleEngine { &mut self, phase: Phase, ctx: &mut RuleContext, + mut trace: Option>, ) -> Result { self.ensure_sorted(); - for rule in &self.rules { + // `rule.apply` is an opaque dyn call that mutates `ctx`, so the reads + // `TraceSpan::open` performs cannot be sunk past it. Gating them on a + // loop-invariant flag keeps the untraced path free of that work. + let tracing = trace.is_some(); + + for (index, rule) in self.rules.iter().enumerate() { if rule.phase() != phase { continue; } @@ -135,7 +149,12 @@ impl RuleEngine { if !rule.matches(ctx) { continue; } - match rule.apply(ctx)? { + let span = tracing.then(|| TraceSpan::open(ctx)); + let outcome = rule.apply(ctx)?; + if let (Some(span), Some(sink)) = (span, trace.as_mut()) { + span.close(RuleId::korean(index), outcome, ctx, sink); + } + match outcome { RuleResult::Consumed => return Ok(RuleResult::Consumed), RuleResult::Continue => {} RuleResult::Skip => {} @@ -146,6 +165,74 @@ impl RuleEngine { } } +struct TraceSpan { + output_start: u32, + char_start: u32, + skip_before: u32, +} + +impl TraceSpan { + fn open(ctx: &RuleContext) -> Self { + Self { + output_start: ctx.result.len() as u32, + char_start: ctx.index as u32, + skip_before: *ctx.skip_count as u32, + } + } + + /// Records by what a rule PRODUCED, not by what it returned. + /// + /// `Skip` normally means the rule declined and explains nothing, so it is + /// dropped — but a few rules emit a mode indicator and still return `Skip` + /// to let the next rule encode the character. Those cells are in the output + /// and something has to account for them. + /// + /// A rule that reported per-article spans (syllable composition) is recorded + /// as those articles instead of as itself, so a syllable names 제3항 for its + /// 받침 rather than one composite entry for the whole character. + fn close( + self, + rule: RuleId, + result: RuleResult, + ctx: &mut RuleContext, + sink: &mut TraceSink<'_>, + ) { + let produced_cells = ctx.result.len() as u32 > self.output_start; + let outcome = match result { + RuleResult::Consumed => RuleOutcome::Consumed, + RuleResult::Continue => RuleOutcome::Continued, + RuleResult::Skip if produced_cells => RuleOutcome::Continued, + RuleResult::Skip => return, + }; + let consumed_extra = (*ctx.skip_count as u32).saturating_sub(self.skip_before); + let word_chars = self.char_start..self.char_start + 1 + consumed_extra; + let end = ctx.result.len() as u32; + + let mut recorded_any = false; + if let Some(spans) = ctx.state.jamo_spans.as_deref_mut() { + for (jamo, span) in spans.drain() { + recorded_any = true; + sink.trace.push(TraceEvent { + rule: RuleId::jamo(jamo), + outcome, + token_index: sink.token_index, + word_chars: word_chars.clone(), + output: self.output_start + span.start..self.output_start + span.end, + }); + } + } + if !recorded_any { + sink.trace.push(TraceEvent { + rule, + outcome, + token_index: sink.token_index, + word_chars, + output: self.output_start..end, + }); + } + } +} + impl Default for RuleEngine { fn default() -> Self { Self::new() @@ -464,7 +551,9 @@ mod tests { }; // TestRule.phase() = CoreEncoding; with disabled section "test", apply_phase // hits the `if !self.is_enabled(meta.section) { continue; }` arm. - let outcome = engine.apply_phase(Phase::CoreEncoding, &mut ctx).unwrap(); + let outcome = engine + .apply_phase(Phase::CoreEncoding, &mut ctx, None) + .unwrap(); assert_eq!(outcome, RuleResult::Skip); } } diff --git a/libs/braillify/src/rules/english_ueb/contraction.rs b/libs/braillify/src/rules/english_ueb/contraction.rs index a8a914e6..146b9170 100644 --- a/libs/braillify/src/rules/english_ueb/contraction.rs +++ b/libs/braillify/src/rules/english_ueb/contraction.rs @@ -26,8 +26,26 @@ pub struct ContractionMatch { pub protect_span: bool, } +/// Placeholder for a contraction rule that has not declared its UEB section yet. +/// Rules keeping this default are reported as unattributed rather than being +/// credited to a section nobody checked against the standard. +pub static UNDECLARED_UEB_RULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "?", + subsection: None, + name: "undeclared_ueb_rule", + standard_ref: "", + description: "", +}; + /// One UEB contraction rule (§10.x). `word` is the lowercased letter slice. pub trait ContractionRule: Send + Sync { + /// The UEB section this rule implements. Defaults to + /// [`UNDECLARED_UEB_RULE`] until someone checks the section against the + /// standard. + fn meta(&self) -> &'static crate::rules::RuleMeta { + &UNDECLARED_UEB_RULE + } + /// Offer a match starting at `pos`, or `None`. fn try_match(&self, word: &[char], pos: usize) -> Option; } @@ -83,6 +101,22 @@ impl ContractionEngine { .collect() } + /// [`Self::matches_at`] with each match paired to its rule's registration + /// index. This is the only place that index is known, and the DP needs it to + /// name the rule behind a match it eventually selects. + pub fn matches_at_indexed(&self, word: &[char], pos: usize) -> Vec<(usize, ContractionMatch)> { + self.rules + .iter() + .enumerate() + .filter_map(|(index, rule)| rule.try_match(word, pos).map(|m| (index, m))) + .collect() + } + + /// Metadata of every registered rule, in registration index order. + pub fn registry(&self) -> Vec<&'static crate::rules::RuleMeta> { + self.rules.iter().map(|rule| rule.meta()).collect() + } + /// Encode a lowercased letter slice to braille cells. /// Returns `None` if a character cannot be encoded as an English letter. #[cfg(test)] diff --git a/libs/braillify/src/rules/english_ueb/engine.rs b/libs/braillify/src/rules/english_ueb/engine.rs index a9a18b80..91f5773b 100644 --- a/libs/braillify/src/rules/english_ueb/engine.rs +++ b/libs/braillify/src/rules/english_ueb/engine.rs @@ -193,6 +193,11 @@ impl EnglishUebEngine { Self { contractions } } + /// Metadata of every contraction rule, in registration index order. + pub(super) fn contraction_rule_metas(&self) -> Vec<&'static crate::rules::RuleMeta> { + self.contractions.registry() + } + /// Encode one Roman word embedded in Korean text according to Korean rule 37. /// /// At a rule-37 Roman entry, the listed whole-word signs are suppressed @@ -544,7 +549,13 @@ impl EnglishUebEngine { 255, ]); } + // Most word branches leave the match with `continue`, so a word cannot be + // checked right after its arm. Carrying the mark to the next iteration + // (and past the loop) reaches every branch without touching any of them. + let mut pending_word: Option<(usize, usize)> = None; + for i in 0..tokens.len() { + super::settle_word_attribution(pending_word.take(), &out); if let Some((end, form)) = nested_inner_passage && i >= end { @@ -629,6 +640,7 @@ impl EnglishUebEngine { EnglishToken::Number(digits) => { skip_flattened_line_indent = false; line_mode_active = false; + let number_start = out.len(); if numeric_mode { // §6.3: already in numeric mode (digit-separator `,`/`.` // bridged us here) — emit digits only, no second `⠼`. @@ -639,6 +651,7 @@ impl EnglishUebEngine { out.extend(super::rule_6::encode_number(digits)?); numeric_separator_count = 0; } + super::record_whole_word(super::UebMoveSource::Numeric, &out[number_start..]); prev_was_number = true; numeric_mode = true; } @@ -649,6 +662,7 @@ impl EnglishUebEngine { numeric_mode = false; } EnglishToken::Word(chars) => { + pending_word = Some((out.len(), super::attempt_count())); encode_word_arm!(self, tokens, explicit_english, out, prev_was_number, numeric_mode, skip_to, line_mode_active, grade1_passage, cap_start_grade1, in_passage, escaped_code, regex_listing, spanish_foreign, foreign_passage, scansion_stress_context, early_english, spatial_grade1_passage, skip_flattened_line_indent, i, chars) } EnglishToken::WordDivision { chars, break_at } if poem_linear_context => { @@ -886,7 +900,9 @@ impl EnglishUebEngine { numeric_mode = true; } EnglishToken::Symbol(c) => { - encode_symbol_arm!(self, tokens, out, prev_was_number, numeric_mode, skip_to, line_mode_active, passage, cap_term, in_passage, url_listing, regex_listing, foreign_code, spanish_foreign, foreign_passage, early_english, preserve_spatial_newlines, skip_flattened_line_indent, numeric_separator_count, i, c) + let symbol_start = out.len(); + encode_symbol_arm!(self, tokens, out, prev_was_number, numeric_mode, skip_to, line_mode_active, passage, cap_term, in_passage, url_listing, regex_listing, foreign_code, spanish_foreign, foreign_passage, early_english, preserve_spatial_newlines, skip_flattened_line_indent, numeric_separator_count, i, c); + super::record_whole_word(super::UebMoveSource::Symbol, &out[symbol_start..]); } EnglishToken::Styled(_, form) => { encode_styled_arm!(self, tokens, out, prev_was_number, numeric_mode, skip_to, passage, in_passage, foreign_code, spanish_foreign, foreign_passage, drop_styled_typeform_for_code_switch, skip_flattened_line_indent, nested_inner_passage, i, form) @@ -897,6 +913,7 @@ impl EnglishUebEngine { out.extend([CAPITAL, decode_unicode('⠄')]); } } + super::settle_word_attribution(pending_word.take(), &out); if let Some(span) = grade1_passage && span.needs_terminator { diff --git a/libs/braillify/src/rules/english_ueb/engine/word_methods.rs b/libs/braillify/src/rules/english_ueb/engine/word_methods.rs index 2e953a7f..d6cdefae 100644 --- a/libs/braillify/src/rules/english_ueb/engine/word_methods.rs +++ b/libs/braillify/src/rules/english_ueb/engine/word_methods.rs @@ -1,4 +1,4 @@ -use super::*; +use super::*; impl EnglishUebEngine { pub(super) fn encode_word( @@ -185,20 +185,29 @@ impl EnglishUebEngine { let cell = upper_usable .then(|| { super::super::rule_10_1::wordsign(&word) - .or_else(|| super::super::rule_10_2::wordsign(&word)) + .map(|c| (c, super::super::UebMoveSource::AlphabeticWordsign)) + .or_else(|| { + super::super::rule_10_2::wordsign(&word) + .map(|c| (c, super::super::UebMoveSource::StrongWordsign)) + }) }) .flatten() .or_else(|| { lower_usable - .then(|| super::super::rule_10_5::wordsign(&word)) + .then(|| { + super::super::rule_10_5::wordsign(&word) + .map(|c| (c, super::super::UebMoveSource::LowerWordsign)) + }) .flatten() }); - if let Some(cell) = cell { + if let Some((cell, source)) = cell { + super::super::record_whole_word(source, &[cell]); out.push(cell); return Some(()); } } if shortform_usable && let Some(cells) = super::super::rule_10_9::whole_word_cells(&word) { + super::super::record_whole_word(super::super::UebMoveSource::Shortform, &cells); out.extend(cells); return Some(()); } diff --git a/libs/braillify/src/rules/english_ueb/mod.rs b/libs/braillify/src/rules/english_ueb/mod.rs index e6e2fbc1..ce8111fb 100644 --- a/libs/braillify/src/rules/english_ueb/mod.rs +++ b/libs/braillify/src/rules/english_ueb/mod.rs @@ -55,6 +55,269 @@ pub mod token; use engine::EnglishUebEngine; +thread_local! { + /// One entry per completed word-encoding attempt, in the order the engine + /// made them. The engine encodes a word under several constraint + /// combinations and keeps one, so most entries describe output that was + /// thrown away; [`align_selected`] separates the kept attempt from the rest. + static ATTEMPTS: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +/// The cells one attempt produced, plus where each rule's cells sat inside them. +struct WordAttempt { + cells: Vec, + moves: Vec<(crate::rules::trace::RuleId, u32, u32)>, +} + +/// Accumulates the moves of one word-encoding attempt. +/// +/// Offsets are taken against the attempt's own output as it is built, because +/// the encoder can insert cells between moves (a §10.13 line break), so a move's +/// position is not the running sum of the moves before it. +pub(super) struct AttemptRecorder { + /// `None` when no trace is being collected, so an untraced encode allocates + /// nothing per word. The check costs one thread-local read per attempt + /// rather than one per move. + moves: Option>, +} + +impl AttemptRecorder { + pub(super) fn new() -> Self { + let collecting = ATTEMPTS.with(|slot| slot.borrow().is_some()); + Self { + moves: collecting.then(Vec::new), + } + } + + pub(super) fn push(&mut self, rule: crate::rules::trace::RuleId, offset: usize, len: usize) { + if let Some(moves) = self.moves.as_mut() { + moves.push((rule, offset as u32, len as u32)); + } + } + + pub(super) fn finish(self, cells: &[u8]) { + let Some(moves) = self.moves else { + return; + }; + ATTEMPTS.with(|slot| { + if let Ok(mut slot) = slot.try_borrow_mut() + && let Some(attempts) = slot.as_mut() + { + attempts.push(WordAttempt { + cells: cells.to_vec(), + moves, + }); + } + }); + } +} + +/// [`try_encode`] plus the rule behind each stretch of the output. +/// +/// A word encoder does not know where its cells land in the finished document, +/// so each attempt's ranges are recovered by locating that attempt's cells in +/// the output. Attempts whose cells are absent were discarded by the engine and +/// contribute nothing. A reported range therefore always points at cells its +/// rule actually produced. +pub(crate) fn try_encode_traced(text: &str) -> Option<(Vec, Vec)> { + let encoded = collect_selected(|| try_encode(text)); + encoded.map(|(cells, moves)| { + let spans = align_selected(&cells, &moves); + (cells, spans) + }) +} + +/// [`encode_forced`] plus the rule behind each stretch of the output. +pub(crate) fn encode_forced_traced(text: &str) -> Option<(Vec, Vec)> { + let encoded = collect_selected(|| encode_forced(text)); + encoded.map(|(cells, moves)| { + let spans = align_selected(&cells, &moves); + (cells, spans) + }) +} + +/// One stretch of output and the UEB rule that produced it. +pub(crate) type UebSpan = (crate::rules::trace::RuleId, core::ops::Range); + +fn collect_selected( + encode: impl FnOnce() -> Option>, +) -> Option<(Vec, Vec)> { + ATTEMPTS.with(|slot| *slot.borrow_mut() = Some(Vec::new())); + let encoded = encode(); + let attempts = ATTEMPTS + .with(|slot| slot.borrow_mut().take()) + .unwrap_or_default(); + encoded.map(|cells| (cells, attempts)) +} + +/// Place each attempt's moves in the finished output, skipping attempts the +/// engine discarded. +/// +/// The scan only moves forward, so an attempt is matched at or after everything +/// already placed. A discarded attempt is recognised by its cells not appearing +/// there — the engine never emitted them. +fn align_selected(cells: &[u8], attempts: &[WordAttempt]) -> Vec { + let mut spans = Vec::new(); + let mut cursor = 0usize; + for attempt in attempts { + let Some(base) = find_from(cells, &attempt.cells, cursor) else { + continue; + }; + for (rule, offset, len) in &attempt.moves { + let start = base + *offset as usize; + let end = start + *len as usize; + spans.push((*rule, start as u32..end as u32)); + } + cursor = base + attempt.cells.len(); + } + // An empty cell between words is the inter-word blank, the same structural + // output the Korean emitter accounts for. It carries no dots, so there is no + // other thing it could be. + let blank = crate::rules::trace::RuleId::emitter(crate::rules::trace::EmitterRule::WordSpace); + for (index, cell) in cells.iter().enumerate() { + if *cell == 0 { + spans.push((blank, index as u32..index as u32 + 1)); + } + } + spans +} + +fn find_from(haystack: &[u8], needle: &[u8], from: usize) -> Option { + if needle.is_empty() || from + needle.len() > haystack.len() { + return None; + } + haystack[from..] + .windows(needle.len()) + .position(|window| window == needle) + .map(|offset| offset + from) +} + +/// Sources of a selected contraction move that are not [`ContractionRule`] +/// objects. They occupy the first slots of the UEB id space so a contraction +/// rule's id stays a fixed offset from its registration index. +/// +/// [`ContractionRule`]: contraction::ContractionRule +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UebMoveSource { + Shortform = 0, + Anglicised = 1, + Letter = 2, + AlphabeticWordsign = 3, + StrongWordsign = 4, + LowerWordsign = 5, + Numeric = 6, + Symbol = 7, +} + +/// Number of non-rule slots reserved before the contraction rules. +pub(crate) const UEB_RESERVED_SLOTS: usize = 8; + +/// Record a whole word that a lookup table resolved in one step, bypassing the +/// contraction search. Without this a wordsign or shortform would leave its +/// cells unexplained even though its section is known exactly. +/// How many attempts have been recorded so far, so a caller can tell whether the +/// encoder it just ran attributed its own output. +pub(super) fn attempt_count() -> usize { + ATTEMPTS.with(|slot| slot.borrow().as_ref().map_or(0, Vec::len)) +} + +/// A word whose attribution has not been settled yet: where its cells start in +/// the output, and how many attempts existed before it ran. +pub(super) type PendingWord = (usize, usize); + +/// Attribute a finished word that nothing else claimed. +/// +/// A word normally names itself through the contraction search or a wordsign +/// lookup. The branches that simply spell it out — letters after a digit, an +/// acronym abutting one — reach neither, and this leaves their cells explained +/// as §4.1 letters without double-counting the words that did claim themselves. +pub(super) fn settle_word_attribution(pending: Option, out: &[u8]) { + let Some((start, attempts_before)) = pending else { + return; + }; + if attempt_count() == attempts_before && out.len() > start { + record_whole_word(UebMoveSource::Letter, &out[start..]); + } +} + +pub(super) fn record_whole_word(source: UebMoveSource, cells: &[u8]) { + let mut attempt = AttemptRecorder::new(); + attempt.push( + crate::rules::trace::RuleId::ueb(source as usize), + 0, + cells.len(), + ); + attempt.finish(cells); +} + +static UEB_NON_RULE_METAS: [crate::rules::RuleMeta; UEB_RESERVED_SLOTS] = [ + crate::rules::RuleMeta { + section: "10.9", + subsection: None, + name: "ueb_shortform", + standard_ref: "UEB 2024 §10.9", + description: "Shortform standing for a longer word", + }, + crate::rules::RuleMeta { + section: "13.2", + subsection: Some("3"), + name: "ueb_anglicised_contraction", + standard_ref: "UEB 2024 §13.2.3", + description: "Contraction in an anglicised or borrowed word", + }, + crate::rules::RuleMeta { + section: "4.1", + subsection: None, + name: "ueb_letter", + standard_ref: "UEB 2024 §4.1 / §4.2", + description: "Uncontracted letter, with an accent indicator where needed", + }, + crate::rules::RuleMeta { + section: "10.1", + subsection: None, + name: "ueb_alphabetic_wordsign", + standard_ref: "UEB 2024 §10.1", + description: "Single letter standing for a whole word", + }, + crate::rules::RuleMeta { + section: "10.2", + subsection: None, + name: "ueb_strong_wordsign", + standard_ref: "UEB 2024 §10.2", + description: "Strong groupsign cell standing for a whole word", + }, + crate::rules::RuleMeta { + section: "10.5", + subsection: None, + name: "ueb_lower_wordsign", + standard_ref: "UEB 2024 §10.5", + description: "Lower-cell sign standing for a whole word", + }, + crate::rules::RuleMeta { + section: "6", + subsection: None, + name: "ueb_numeric", + standard_ref: "UEB 2024 §6", + description: "Numeric indicator and the digits that follow it", + }, + crate::rules::RuleMeta { + section: "3", + subsection: None, + name: "ueb_symbol", + standard_ref: "UEB 2024 §3", + description: "General symbol such as percent, ampersand or asterisk", + }, +]; + +/// Metadata of every UEB move source, in [`crate::rules::trace::RuleId`] order: +/// the reserved non-rule slots first, then the contraction rules. +pub(crate) fn ueb_rule_registry() -> Vec<&'static crate::rules::RuleMeta> { + let mut metas: Vec<&'static crate::rules::RuleMeta> = UEB_NON_RULE_METAS.iter().collect(); + metas.extend(EnglishUebEngine::new().contraction_rule_metas()); + metas +} + /// Attempt to encode `text` as standalone UEB Grade-2. Returns `None` if the /// input is empty or contains a construct the engine does not yet support, so /// the caller can fall back to the legacy encoding path. diff --git a/libs/braillify/src/rules/english_ueb/rule_10_11.rs b/libs/braillify/src/rules/english_ueb/rule_10_11.rs index 32d5565e..297bede8 100644 --- a/libs/braillify/src/rules/english_ueb/rule_10_11.rs +++ b/libs/braillify/src/rules/english_ueb/rule_10_11.rs @@ -60,7 +60,19 @@ fn is_bridging_digraph(a: char, b: char) -> bool { /// splits its two letters) is left to spell out. pub struct BridgeAwareStrongGroupsignRule; +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "10.11", + subsection: None, + name: "ueb_bridge_aware_strong_groupsign", + standard_ref: "UEB 2024 §10.11", + description: "Strong groupsign that must not bridge a compound boundary", +}; + impl ContractionRule for BridgeAwareStrongGroupsignRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn try_match(&self, word: &[char], pos: usize) -> Option { let m = StrongGroupsignRule.try_match(word, pos)?; if m.consumed == 2 diff --git a/libs/braillify/src/rules/english_ueb/rule_10_3.rs b/libs/braillify/src/rules/english_ueb/rule_10_3.rs index 796364d6..b0080a51 100644 --- a/libs/braillify/src/rules/english_ueb/rule_10_3.rs +++ b/libs/braillify/src/rules/english_ueb/rule_10_3.rs @@ -25,7 +25,19 @@ pub fn is_strong_contraction_word(word: &str) -> bool { /// §10.3 strong contraction rule. pub struct StrongContractionRule; +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "10.3", + subsection: None, + name: "ueb_strong_contraction", + standard_ref: "UEB 2024 §10.3", + description: "Strong contractions: and, for, of, the, with", +}; + impl ContractionRule for StrongContractionRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn try_match(&self, word: &[char], pos: usize) -> Option { match_longest(word, pos, &STRONG, 50) } diff --git a/libs/braillify/src/rules/english_ueb/rule_10_6_8.rs b/libs/braillify/src/rules/english_ueb/rule_10_6_8.rs index 67e88521..3d63839b 100644 --- a/libs/braillify/src/rules/english_ueb/rule_10_6_8.rs +++ b/libs/braillify/src/rules/english_ueb/rule_10_6_8.rs @@ -37,7 +37,19 @@ impl EnInBeforeNessRule { } } +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "10.6", + subsection: Some("8"), + name: "ueb_en_in_before_ness", + standard_ref: "UEB 2024 §10.6.8", + description: "en/in kept or dropped where they overlap a final ness", +}; + impl ContractionRule for EnInBeforeNessRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn try_match(&self, word: &[char], pos: usize) -> Option { let mut m = LowerGroupsignRule.try_match(word, pos)?; // §10.6.8: where `en`/`in` overlaps a following `ness` at the shared `n`, diff --git a/libs/braillify/src/rules/english_ueb/rule_10_6_middle.rs b/libs/braillify/src/rules/english_ueb/rule_10_6_middle.rs index c59f1627..782e81a0 100644 --- a/libs/braillify/src/rules/english_ueb/rule_10_6_middle.rs +++ b/libs/braillify/src/rules/english_ueb/rule_10_6_middle.rs @@ -291,7 +291,19 @@ fn bridges_compound_seam(word: &[char], pos: usize, consumed: usize) -> bool { .any(|&seam| pos < seam && seam < pos + consumed) } +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "10.6", + subsection: Some("5"), + name: "ueb_middle_lower_groupsign", + standard_ref: "UEB 2024 §10.6.5", + description: "Middle lower groupsigns ea bb cc ff gg, morpheme-gated", +}; + impl ContractionRule for MiddleLowerGroupsignRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn try_match(&self, word: &[char], pos: usize) -> Option { let m = middle_lower_groupsign(word, pos)?; // `middle_lower_groupsign` only matches `ea` or a doubled letter diff --git a/libs/braillify/src/rules/english_ueb/rule_10_6_restricted.rs b/libs/braillify/src/rules/english_ueb/rule_10_6_restricted.rs index 4132b6dc..d343518e 100644 --- a/libs/braillify/src/rules/english_ueb/rule_10_6_restricted.rs +++ b/libs/braillify/src/rules/english_ueb/rule_10_6_restricted.rs @@ -24,7 +24,19 @@ impl RestrictedLowerGroupsignRule { } } +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "10.6", + subsection: Some("restricted"), + name: "ueb_restricted_lower_groupsign", + standard_ref: "UEB 2024 §10.6", + description: "Restricted lower groupsigns be, con, dis", +}; + impl ContractionRule for RestrictedLowerGroupsignRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn try_match(&self, word: &[char], pos: usize) -> Option { // Restricted groupsigns are word-initial only (§10.6.2). if pos != 0 { diff --git a/libs/braillify/src/rules/english_ueb/rule_10_7.rs b/libs/braillify/src/rules/english_ueb/rule_10_7.rs index db77e3ae..4a964580 100644 --- a/libs/braillify/src/rules/english_ueb/rule_10_7.rs +++ b/libs/braillify/src/rules/english_ueb/rule_10_7.rs @@ -88,7 +88,19 @@ pub fn is_initial_letter_contraction_word(word: &str) -> bool { /// §10.7 initial-letter contraction rule. pub struct InitialContractionRule; +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "10.7", + subsection: None, + name: "ueb_initial_contraction", + standard_ref: "UEB 2024 §10.7", + description: "Initial-letter contractions standing for whole words", +}; + impl ContractionRule for InitialContractionRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn try_match(&self, word: &[char], pos: usize) -> Option { let mut best: Option<(usize, [u8; 2])> = None; for (key, &cells) in INITIAL_CONTRACTIONS.entries() { diff --git a/libs/braillify/src/rules/english_ueb/rule_10_7_pron.rs b/libs/braillify/src/rules/english_ueb/rule_10_7_pron.rs index 68829a28..5a87fc22 100644 --- a/libs/braillify/src/rules/english_ueb/rule_10_7_pron.rs +++ b/libs/braillify/src/rules/english_ueb/rule_10_7_pron.rs @@ -264,7 +264,19 @@ impl InitialContractionPronunciationRule { } } +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "10.7", + subsection: Some("pronunciation"), + name: "ueb_initial_contraction_pronunciation", + standard_ref: "UEB 2024 §10.7", + description: "Initial-letter contractions gated by pronunciation", +}; + impl ContractionRule for InitialContractionPronunciationRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn try_match(&self, word: &[char], pos: usize) -> Option { let full: String = word.iter().collect(); let mut best: Option<(usize, [u8; 2])> = None; diff --git a/libs/braillify/src/rules/english_ueb/rule_10_7_struct.rs b/libs/braillify/src/rules/english_ueb/rule_10_7_struct.rs index d6bdf90f..4efc7046 100644 --- a/libs/braillify/src/rules/english_ueb/rule_10_7_struct.rs +++ b/libs/braillify/src/rules/english_ueb/rule_10_7_struct.rs @@ -93,7 +93,19 @@ impl StructuralInitialContractionRule { } } +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "10.7", + subsection: Some("structure"), + name: "ueb_initial_contraction_structural", + standard_ref: "UEB 2024 §10.7", + description: "Initial-letter contractions gated by morpheme structure", +}; + impl ContractionRule for StructuralInitialContractionRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn try_match(&self, word: &[char], pos: usize) -> Option { for (key, &cells) in STRUCT_CONTRACTIONS.entries() { let klen = key.chars().count(); diff --git a/libs/braillify/src/rules/english_ueb/rule_10_8.rs b/libs/braillify/src/rules/english_ueb/rule_10_8.rs index 1c523c74..4e1b28be 100644 --- a/libs/braillify/src/rules/english_ueb/rule_10_8.rs +++ b/libs/braillify/src/rules/english_ueb/rule_10_8.rs @@ -53,7 +53,19 @@ fn ness_exception(word: &[char]) -> bool { /// §10.8 final-letter groupsign rule. pub struct FinalGroupsignRule; +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "10.8", + subsection: None, + name: "ueb_final_groupsign", + standard_ref: "UEB 2024 §10.8", + description: "Final-letter groupsigns for word-final letter clusters", +}; + impl ContractionRule for FinalGroupsignRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn try_match(&self, word: &[char], pos: usize) -> Option { // §10.8: never used at the start of a word. if pos == 0 { diff --git a/libs/braillify/src/rules/english_ueb/rule_10_9.rs b/libs/braillify/src/rules/english_ueb/rule_10_9.rs index 214aa22f..e8d23d47 100644 --- a/libs/braillify/src/rules/english_ueb/rule_10_9.rs +++ b/libs/braillify/src/rules/english_ueb/rule_10_9.rs @@ -7,9 +7,11 @@ use phf::phf_map; +use super::UebMoveSource; use super::contraction::{ContractionEngine, ContractionMatch}; use super::rule_10_13::WordDivision; use crate::english::encode_english; +use crate::rules::trace::RuleId; use crate::unicode::decode_unicode; static SHORTFORMS: phf::Map<&'static str, &'static str> = phf_map! { @@ -355,12 +357,12 @@ fn encode_with_constraints( // lower-preference groupsign that overlaps its start here (`en`, 70): // `re·name·d`, not `r·en·amed`; `mis·time·d`, not `mis·st·imed`. let mut path_priority = vec![u16::MAX; n + 1]; - let mut back: Vec, usize)>> = vec![None; n + 1]; + let mut back: Vec, usize, RuleId)>> = vec![None; n + 1]; cost[n] = 0; for pos in (0..n).rev() { - // Best candidate so far: (total cells, path priority, consumed, cells). - let mut best: Option<(usize, u16, usize, Vec)> = None; - for (cells, consumed, priority) in candidate_moves( + // Best candidate so far: (total cells, path priority, consumed, cells, source). + let mut best: Option<(usize, u16, usize, Vec, RuleId)> = None; + for (cells, consumed, priority, source) in candidate_moves( word, pos, contractions, @@ -379,31 +381,36 @@ fn encode_with_constraints( // The preference of the whole remaining path: the best contraction in // this move or anything the tail already chose. let this_priority = priority.min(path_priority[next]); - let better = best.as_ref().is_none_or(|(bt, bp, bc, _)| { + let better = best.as_ref().is_none_or(|(bt, bp, bc, _, _)| { total < *bt || (total == *bt && this_priority < *bp) || (total == *bt && this_priority == *bp && consumed > *bc) }); if better { - best = Some((total, this_priority, consumed, cells)); + best = Some((total, this_priority, consumed, cells, source)); } } - let (total, pp, consumed, cells) = best?; + let (total, pp, consumed, cells, source) = best?; cost[pos] = total; path_priority[pos] = pp; - back[pos] = Some((cells, consumed)); + back[pos] = Some((cells, consumed, source)); } - // Reconstruct the chosen sequence from the start. + // Reconstruct the chosen sequence from the start. Only the moves on this + // path produced output; the candidates the DP rejected did not, so this walk + // is the only place a contraction may be credited for a cell. let mut out = Vec::with_capacity(cost[0]); + let mut attempt = super::AttemptRecorder::new(); let mut pos = 0; while pos < n { - let (cells, consumed) = back[pos].as_ref()?; + let (cells, consumed, source) = back[pos].as_ref()?; + attempt.push(*source, out.len(), cells.len()); out.extend(cells.iter().copied()); pos += consumed; if division.is_some_and(|d| pos == d.index) { super::rule_10_13::append_break(&mut out, true); } } + attempt.finish(&out); Some(out) } @@ -428,7 +435,7 @@ fn candidate_moves( allow_longer_shortforms: bool, relax_shortforms: bool, suppress_whole_word_wordsign: bool, -) -> Vec<(Vec, usize, u16)> { +) -> Vec<(Vec, usize, u16, RuleId)> { let mut moves = Vec::new(); // §10.9 longer-word shortform placement (preferred on a cost tie → priority 0). if allow_longer_shortforms { @@ -442,14 +449,14 @@ fn candidate_moves( if let Some((len, cells)) = longer && division.is_none_or(|d| !d.blocks_span(pos, len)) { - moves.push((cells, len, 0)); + moves.push((cells, len, 0, source_id(UebMoveSource::Shortform))); } } if relax_shortforms && let Some((cells, len)) = anglicised_initial_contraction(word, pos) { - moves.push((cells, len, 55)); + moves.push((cells, len, 55, source_id(UebMoveSource::Anglicised))); } let protected_here = inside_protected[pos]; - for m in contractions.matches_at(word, pos) { + for (rule_index, m) in contractions.matches_at_indexed(word, pos) { // Korean rule 37: immediately after the Roman indicator, a lower // wordsign is written with alphabet/multi-letter groupsigns instead. // Reject only a contraction consuming the complete wordsign; inner @@ -565,19 +572,27 @@ fn candidate_moves( }) { continue; } - moves.push((m.cells, m.consumed, m.priority)); + moves.push((m.cells, m.consumed, m.priority, rule_id(rule_index))); } // §4.2 accent / §4.1 single letter — always available so the DP never stalls. if let Some(cells) = super::rule_12::early_letter(word[pos]) { - moves.push((cells, 1, u16::MAX)); + moves.push((cells, 1, u16::MAX, source_id(UebMoveSource::Letter))); } else if let Some(cells) = super::rule_4::accent_cells(word[pos]) { - moves.push((cells, 1, u16::MAX)); + moves.push((cells, 1, u16::MAX, source_id(UebMoveSource::Letter))); } else if let Ok(cell) = encode_english(word[pos]) { - moves.push((vec![cell], 1, u16::MAX)); + moves.push((vec![cell], 1, u16::MAX, source_id(UebMoveSource::Letter))); } moves } +fn source_id(source: super::UebMoveSource) -> RuleId { + RuleId::ueb(source as usize) +} + +fn rule_id(rule_index: usize) -> RuleId { + RuleId::ueb(super::UEB_RESERVED_SLOTS + rule_index) +} + /// §13.2.3 anglicised words may use ordinary UEB contractions even when CMUdict /// has no entry for the borrowed/proper word. Initial-letter contractions whose /// English phonology gate cannot fire for an unrecorded word are safe when the @@ -1001,7 +1016,7 @@ mod tests { false, false, ); - assert!(moves.iter().all(|(cells, consumed, _)| { + assert!(moves.iter().all(|(cells, consumed, _, _)| { *consumed != pattern.len() || cells != &vec![decode_unicode('⠆')] })); } @@ -1131,7 +1146,7 @@ mod tests { false, ); - assert!(moves.iter().any(|(cells, consumed, priority)| { + assert!(moves.iter().any(|(cells, consumed, priority, _)| { *cells == vec![decode_unicode('⠵')] && *consumed == 1 && *priority == u16::MAX })); } @@ -1324,7 +1339,7 @@ mod tests { false, false, ); - assert!(moves.iter().any(|(cells_, consumed, priority)| { + assert!(moves.iter().any(|(cells_, consumed, priority, _)| { *cells_ == cells("⠼⠮") && *consumed == 1 && *priority == u16::MAX })); } diff --git a/libs/braillify/src/rules/korean/rule_72.rs b/libs/braillify/src/rules/korean/rule_72.rs index 96fd8aec..330f4aec 100644 --- a/libs/braillify/src/rules/korean/rule_72.rs +++ b/libs/braillify/src/rules/korean/rule_72.rs @@ -116,6 +116,10 @@ fn owned_word(text: String) -> Token<'static> { pub struct Rule72AttachedMarkerTokenRule; impl TokenRule for Rule72AttachedMarkerTokenRule { + fn meta(&self) -> &'static RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { TokenPhase::Normalization } diff --git a/libs/braillify/src/rules/korean/rule_korean.rs b/libs/braillify/src/rules/korean/rule_korean.rs index 9c11281f..916d1ac5 100644 --- a/libs/braillify/src/rules/korean/rule_korean.rs +++ b/libs/braillify/src/rules/korean/rule_korean.rs @@ -8,8 +8,8 @@ //! and 13 (single-char abbreviation), serving as the general-purpose fallback //! for Korean syllables that weren't caught by those specialized rules. -use crate::char_struct::CharType; -use crate::korean_char::encode_korean_char; +use crate::char_struct::{CharType, KoreanChar}; +use crate::korean_char::{JamoSpans, encode_korean_char, encode_korean_char_with_spans}; use crate::rules::RuleMeta; use crate::rules::context::RuleContext; use crate::rules::traits::{BrailleRule, Phase, RuleResult}; @@ -50,7 +50,21 @@ impl BrailleRule for RuleKorean { let Some(korean) = ctx.as_korean() else { return Ok(RuleResult::Skip); }; - let encoded = encode_korean_char(korean)?; + if ctx.state.jamo_spans.is_none() { + let encoded = encode_korean_char(korean)?; + ctx.emit_slice(&encoded); + return Ok(RuleResult::Consumed); + } + let korean = KoreanChar { + cho: korean.cho, + jung: korean.jung, + jong: korean.jong, + }; + let mut spans = JamoSpans::default(); + let encoded = encode_korean_char_with_spans(&korean, &mut spans)?; + if let Some(slot) = ctx.state.jamo_spans.as_deref_mut() { + *slot = spans; + } ctx.emit_slice(&encoded); Ok(RuleResult::Consumed) } diff --git a/libs/braillify/src/rules/math/encoder.rs b/libs/braillify/src/rules/math/encoder.rs index ce19324d..8d35ea5a 100644 --- a/libs/braillify/src/rules/math/encoder.rs +++ b/libs/braillify/src/rules/math/encoder.rs @@ -361,6 +361,13 @@ static MATRIX_MATH_MODE_ENGINE: LazyLock = LazyLock::new(|| { }) }); +/// Metadata of every math rule, in [`crate::rules::trace::RuleId`] order. All +/// four context engines register the same rules in the same order, so one +/// engine's ordering describes them all. +pub(crate) fn math_rule_registry() -> Vec<&'static crate::rules::RuleMeta> { + DEFAULT_MATH_ENGINE.registry() +} + pub(super) fn math_engine_for_context(context: MathContext) -> &'static MathTokenEngine { match (context.matrix_context_active, context.math_mode_active) { (false, false) => &DEFAULT_MATH_ENGINE, @@ -407,12 +414,29 @@ fn build_math_engine(context: MathContext) -> MathTokenEngine { /// Encode a full math expression string into braille bytes. pub fn encode_math_expression(input: &str) -> Result, String> { + encode_math_expression_traced(input, MathContext::default(), None) +} + +pub(crate) fn encode_math_expression_traced( + input: &str, + context: MathContext, + trace: Option<&mut crate::rules::trace::TraceSink<'_>>, +) -> Result, String> { if rule_14::is_roman_numeral_expression(input) { return rule_14::encode_roman_numeral_expression(input); } - let tokens = super::parser::parse_math_expression(input)?; - encode_math_tokens_with_context(&tokens, MathContext::default()) + // A non-default context parses with the math-mode parser; the two parsers + // disagree on tokenisation, so this branch decides the output. + let tokens = if context == MathContext::default() { + super::parser::parse_math_expression(input)? + } else { + super::parser::parse_math_expression_with_math_mode(input, context.math_mode_active)? + }; + let engine = math_engine_for_context(context); + let mut result = Vec::new(); + engine.encode_tokens_traced(&tokens, &mut result, trace)?; + Ok(result) } /// Encode a full math expression string with encoder-scoped context flags. @@ -423,24 +447,7 @@ pub fn encode_math_expression_with_context( if context == MathContext::default() { return encode_math_expression(input); } - - if rule_14::is_roman_numeral_expression(input) { - return rule_14::encode_roman_numeral_expression(input); - } - - let tokens = - super::parser::parse_math_expression_with_math_mode(input, context.math_mode_active)?; - encode_math_tokens_with_context(&tokens, context) -} - -fn encode_math_tokens_with_context( - tokens: &[MathToken], - context: MathContext, -) -> Result, String> { - let engine = math_engine_for_context(context); - let mut result = Vec::new(); - engine.encode_tokens(tokens, &mut result)?; - Ok(result) + encode_math_expression_traced(input, context, None) } #[cfg(test)] diff --git a/libs/braillify/src/rules/math/math_token_rule.rs b/libs/braillify/src/rules/math/math_token_rule.rs index f74727e6..2cec0f45 100644 --- a/libs/braillify/src/rules/math/math_token_rule.rs +++ b/libs/braillify/src/rules/math/math_token_rule.rs @@ -39,11 +39,30 @@ pub enum MathTokenResult { Skip, } +use crate::rules::trace::{RuleId, TraceSink}; + +/// Placeholder for a math rule that has not declared its source article yet. +/// Rules keeping this default are reported as unattributed rather than being +/// credited to an article nobody checked against the standard. +pub static UNDECLARED_MATH_RULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "?", + subsection: None, + name: "undeclared_math_rule", + standard_ref: "", + description: "", +}; + /// Plugin interface for math token encoding rules. pub trait MathTokenRule: Send + Sync { /// Rule name for debugging. fn name(&self) -> &'static str; + /// The standard article this rule implements. Defaults to + /// [`UNDECLARED_MATH_RULE`] until someone checks the article against the PDF. + fn meta(&self) -> &'static crate::rules::RuleMeta { + &UNDECLARED_MATH_RULE + } + /// Priority (lower runs first). Default: 100. fn priority(&self) -> u16 { 100 @@ -86,20 +105,51 @@ impl MathTokenEngine { self.rules.sort_by_key(|r| r.priority()); } + /// Metadata of every registered math rule, in [`RuleId`] order. + pub(crate) fn registry(&self) -> Vec<&'static crate::rules::RuleMeta> { + self.rules.iter().map(|rule| rule.meta()).collect() + } + /// Encode a sequence of math tokens into braille bytes. pub fn encode_tokens(&self, tokens: &[MathToken], result: &mut Vec) -> Result<(), String> { + self.encode_tokens_traced(tokens, result, None) + } + + /// [`Self::encode_tokens`], recording which rule produced each stretch. + /// + /// The spans go to a collector rather than to a sink parameter because a math + /// expression usually reaches here from a token rule, which emits the cells + /// much later without knowing where they land. [`crate::rules::emit`] pairs + /// the collected spans back to those cells. + pub(crate) fn encode_tokens_traced( + &self, + tokens: &[MathToken], + result: &mut Vec, + mut trace: Option<&mut TraceSink<'_>>, + ) -> Result<(), String> { + let mut attempt = super::MathAttempt::new(); + let attempt_base = result.len(); let logic_context = Self::has_logic_symbol(tokens); let mut state = MathEncodeState::with_context(logic_context, self.context); let mut i = 0usize; while i < tokens.len() { let mut handled = false; - for rule in &self.rules { + for (rule_index, rule) in self.rules.iter().enumerate() { let _ = rule.name(); - if rule.matches(tokens, i, &state) - && let MathTokenResult::Consumed(n) = + if rule.matches(tokens, i, &state) { + let start = result.len(); + let MathTokenResult::Consumed(n) = rule.apply(tokens, i, result, &mut state, self)? - { + else { + continue; + }; + let rule_id = RuleId::math(rule_index); + attempt.push(rule_id, start - attempt_base, result.len() - start); + if let Some(sink) = trace.as_deref_mut() { + let token_index = sink.token_index() as usize; + sink.record_span(rule_id, token_index, start..result.len()); + } i += n; handled = true; break; @@ -112,6 +162,7 @@ impl MathTokenEngine { )); } } + attempt.finish(&result[attempt_base..]); Ok(()) } diff --git a/libs/braillify/src/rules/math/mod.rs b/libs/braillify/src/rules/math/mod.rs index 9dce6f99..721a4490 100644 --- a/libs/braillify/src/rules/math/mod.rs +++ b/libs/braillify/src/rules/math/mod.rs @@ -12,6 +12,73 @@ pub mod function; pub mod math_token_rule; pub mod parser; +thread_local! { + /// Rule spans of each math expression encoded during one traced encode. + /// + /// A math expression is normally encoded by a token rule, which emits the + /// cells into the document much later. Holding the spans here lets + /// [`super::emit`] pair them back to those cells instead of reporting the + /// whole expression as one token-rule span. + static MATH_ATTEMPTS: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +type MathSpans = (Vec, Vec<(super::trace::RuleId, u32, u32)>); + +/// Collects the rule spans of one math expression. +pub(crate) struct MathAttempt { + moves: Option>, +} + +impl MathAttempt { + pub(crate) fn new() -> Self { + let collecting = MATH_ATTEMPTS.with(|slot| slot.borrow().is_some()); + Self { + moves: collecting.then(Vec::new), + } + } + + pub(crate) fn push(&mut self, rule: super::trace::RuleId, offset: usize, len: usize) { + if let Some(moves) = self.moves.as_mut() { + moves.push((rule, offset as u32, len as u32)); + } + } + + pub(crate) fn finish(self, cells: &[u8]) { + let Some(moves) = self.moves else { + return; + }; + MATH_ATTEMPTS.with(|slot| { + if let Ok(mut slot) = slot.try_borrow_mut() + && let Some(attempts) = slot.as_mut() + { + attempts.push((cells.to_vec(), moves)); + } + }); + } +} + +pub(crate) fn begin_collection() { + MATH_ATTEMPTS.with(|slot| *slot.borrow_mut() = Some(Vec::new())); +} + +pub(crate) fn end_collection() { + MATH_ATTEMPTS.with(|slot| *slot.borrow_mut() = None); +} + +/// Spans of the math expression whose output is exactly `cells`, if one was +/// encoded during this trace. +pub(crate) fn spans_for(cells: &[u8]) -> Option> { + MATH_ATTEMPTS.with(|slot| { + let slot = slot.try_borrow().ok()?; + let attempts = slot.as_ref()?; + attempts + .iter() + .find(|(produced, _)| produced == cells) + .map(|(_, moves)| moves.clone()) + }) +} + // ── 제1항–제10항: 숫자, 연산, 등식, 비교, 괄호, 분수, 소수, 비 ── pub mod rule_1; pub mod rule_10; diff --git a/libs/braillify/src/rules/math/rule_1.rs b/libs/braillify/src/rules/math/rule_1.rs index 1cef83b5..4ad8d726 100644 --- a/libs/braillify/src/rules/math/rule_1.rs +++ b/libs/braillify/src/rules/math/rule_1.rs @@ -33,7 +33,19 @@ pub fn encode_number_literal(digits: &str, result: &mut Vec) { pub struct NumberRule; +static META_NUMBERRULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "1", + subsection: None, + name: "math_number", + standard_ref: "2024 Korean Braille Standard, 수학 제1항", + description: "수 표기", +}; + impl MathTokenRule for NumberRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_NUMBERRULE + } + fn name(&self) -> &'static str { "NumberRule" } diff --git a/libs/braillify/src/rules/math/rule_12.rs b/libs/braillify/src/rules/math/rule_12.rs index d82bd7d7..abbd25dc 100644 --- a/libs/braillify/src/rules/math/rule_12.rs +++ b/libs/braillify/src/rules/math/rule_12.rs @@ -362,7 +362,19 @@ pub fn encode_upper_variable( pub struct CombinatoricsRule; +static META_COMBINATORICSRULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "12", + subsection: None, + name: "math_combinatorics", + standard_ref: "2024 Korean Braille Standard, 수학 제12항", + description: "순열·조합", +}; + impl MathTokenRule for CombinatoricsRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_COMBINATORICSRULE + } + fn name(&self) -> &'static str { "CombinatoricsRule" } @@ -415,7 +427,19 @@ impl MathTokenRule for CombinatoricsRule { pub struct VariableRule; +static META_VARIABLERULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "12", + subsection: None, + name: "math_variable", + standard_ref: "2024 Korean Braille Standard, 수학 제12항", + description: "소문자 변수", +}; + impl MathTokenRule for VariableRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_VARIABLERULE + } + fn name(&self) -> &'static str { "VariableRule" } @@ -457,7 +481,19 @@ impl MathTokenRule for VariableRule { pub struct UpperVariableRule; +static META_UPPERVARIABLERULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "12", + subsection: None, + name: "math_upper_variable", + standard_ref: "2024 Korean Braille Standard, 수학 제12항", + description: "대문자 변수", +}; + impl MathTokenRule for UpperVariableRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_UPPERVARIABLERULE + } + fn name(&self) -> &'static str { "UpperVariableRule" } diff --git a/libs/braillify/src/rules/math/rule_18.rs b/libs/braillify/src/rules/math/rule_18.rs index 1d6c19ea..92cf54fc 100644 --- a/libs/braillify/src/rules/math/rule_18.rs +++ b/libs/braillify/src/rules/math/rule_18.rs @@ -267,7 +267,19 @@ pub fn encode_superscript( pub struct SuperscriptRule; +static META_SUPERSCRIPTRULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "18", + subsection: None, + name: "math_superscript", + standard_ref: "2024 Korean Braille Standard, 수학 제18항", + description: "위첨자", +}; + impl MathTokenRule for SuperscriptRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_SUPERSCRIPTRULE + } + fn name(&self) -> &'static str { "SuperscriptRule" } diff --git a/libs/braillify/src/rules/math/rule_19.rs b/libs/braillify/src/rules/math/rule_19.rs index b95c715a..63fb1959 100644 --- a/libs/braillify/src/rules/math/rule_19.rs +++ b/libs/braillify/src/rules/math/rule_19.rs @@ -270,7 +270,19 @@ fn needs_quantifier_trailing_space(tokens: &[MathToken], idx: usize) -> bool { pub struct SubscriptRule; +static META_SUBSCRIPTRULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "19", + subsection: None, + name: "math_subscript", + standard_ref: "2024 Korean Braille Standard, 수학 제19항", + description: "아래첨자", +}; + impl MathTokenRule for SubscriptRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_SUBSCRIPTRULE + } + fn name(&self) -> &'static str { "SubscriptRule" } diff --git a/libs/braillify/src/rules/math/rule_2.rs b/libs/braillify/src/rules/math/rule_2.rs index 2bef2018..01caaf25 100644 --- a/libs/braillify/src/rules/math/rule_2.rs +++ b/libs/braillify/src/rules/math/rule_2.rs @@ -367,7 +367,19 @@ mod tests { pub struct OperatorRule; +static META_OPERATORRULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "2", + subsection: None, + name: "math_operator", + standard_ref: "2024 Korean Braille Standard, 수학 제2항", + description: "연산 기호", +}; + impl MathTokenRule for OperatorRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_OPERATORRULE + } + fn name(&self) -> &'static str { "OperatorRule" } diff --git a/libs/braillify/src/rules/math/rule_47.rs b/libs/braillify/src/rules/math/rule_47.rs index dabc0b5f..a05d1d9b 100644 --- a/libs/braillify/src/rules/math/rule_47.rs +++ b/libs/braillify/src/rules/math/rule_47.rs @@ -276,7 +276,19 @@ fn next_is_lim_body(tokens: &[MathToken], idx: usize) -> bool { pub struct FunctionNameRule; +static META_FUNCTIONNAMERULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "47", + subsection: None, + name: "math_function_name", + standard_ref: "2024 Korean Braille Standard, 수학 제47항", + description: "함수 이름", +}; + impl MathTokenRule for FunctionNameRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_FUNCTIONNAMERULE + } + fn name(&self) -> &'static str { "FunctionNameRule" } diff --git a/libs/braillify/src/rules/math/rule_53.rs b/libs/braillify/src/rules/math/rule_53.rs index 8e746fdd..9846eba2 100644 --- a/libs/braillify/src/rules/math/rule_53.rs +++ b/libs/braillify/src/rules/math/rule_53.rs @@ -11,7 +11,19 @@ pub fn encode_prime(result: &mut Vec) { pub struct PrimeRule; +static META_PRIMERULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "53", + subsection: None, + name: "math_prime", + standard_ref: "2024 Korean Braille Standard, 수학 제53항", + description: "프라임 기호", +}; + impl MathTokenRule for PrimeRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_PRIMERULE + } + fn name(&self) -> &'static str { "PrimeRule" } diff --git a/libs/braillify/src/rules/math/rule_54.rs b/libs/braillify/src/rules/math/rule_54.rs index 9fb9276d..3ed57b87 100644 --- a/libs/braillify/src/rules/math/rule_54.rs +++ b/libs/braillify/src/rules/math/rule_54.rs @@ -43,7 +43,19 @@ fn is_slash_operator(tok: Option<&MathToken>) -> bool { pub struct PartialDerivativeFractionRule; +static META_PARTIALDERIVATIVEFRACTIONRULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "54", + subsection: None, + name: "math_partial_derivative", + standard_ref: "2024 Korean Braille Standard, 수학 제54항", + description: "편미분 분수", +}; + impl MathTokenRule for PartialDerivativeFractionRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_PARTIALDERIVATIVEFRACTIONRULE + } + fn name(&self) -> &'static str { "PartialDerivativeFractionRule" } diff --git a/libs/braillify/src/rules/math/rule_57.rs b/libs/braillify/src/rules/math/rule_57.rs index 2625de6d..cbf75b16 100644 --- a/libs/braillify/src/rules/math/rule_57.rs +++ b/libs/braillify/src/rules/math/rule_57.rs @@ -40,7 +40,19 @@ fn split_definite_integral_bounds( pub struct DefiniteIntegralRule; +static META_DEFINITEINTEGRALRULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "57", + subsection: None, + name: "math_definite_integral", + standard_ref: "2024 Korean Braille Standard, 수학 제57항", + description: "정적분", +}; + impl MathTokenRule for DefiniteIntegralRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_DEFINITEINTEGRALRULE + } + fn name(&self) -> &'static str { "DefiniteIntegralRule" } diff --git a/libs/braillify/src/rules/math/rule_6.rs b/libs/braillify/src/rules/math/rule_6.rs index 6b5107e6..2f5d5cea 100644 --- a/libs/braillify/src/rules/math/rule_6.rs +++ b/libs/braillify/src/rules/math/rule_6.rs @@ -62,7 +62,19 @@ pub fn find_matching_paren(tokens: &[MathToken], start: usize) -> Option pub struct BracketRule; +static META_BRACKETRULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "6", + subsection: None, + name: "math_bracket", + standard_ref: "2024 Korean Braille Standard, 수학 제6항", + description: "괄호", +}; + impl MathTokenRule for BracketRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_BRACKETRULE + } + fn name(&self) -> &'static str { "BracketRule" } diff --git a/libs/braillify/src/rules/math/rule_7.rs b/libs/braillify/src/rules/math/rule_7.rs index c88d8d81..6345be96 100644 --- a/libs/braillify/src/rules/math/rule_7.rs +++ b/libs/braillify/src/rules/math/rule_7.rs @@ -47,7 +47,19 @@ pub struct FractionReversalRule; pub struct GroupedFractionReversalRule; +static META_GROUPEDFRACTIONREVERSALRULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "7", + subsection: None, + name: "math_grouped_fraction", + standard_ref: "2024 Korean Braille Standard, 수학 제7항", + description: "묶음 분수 - 분모 먼저", +}; + impl MathTokenRule for GroupedFractionReversalRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_GROUPEDFRACTIONREVERSALRULE + } + fn name(&self) -> &'static str { "GroupedFractionReversalRule" } @@ -152,7 +164,19 @@ fn find_simple_right_end(tokens: &[MathToken], start: usize) -> usize { i } +static META_FRACTIONREVERSALRULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "7", + subsection: None, + name: "math_fraction", + standard_ref: "2024 Korean Braille Standard, 수학 제7항", + description: "분수 - 분모 먼저", +}; + impl MathTokenRule for FractionReversalRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_FRACTIONREVERSALRULE + } + fn name(&self) -> &'static str { "FractionReversalRule" } @@ -194,7 +218,19 @@ impl MathTokenRule for FractionReversalRule { /// `f/x` → `x/f` (분모 먼저). 안전을 위해 prev가 OpenParen 또는 comma일 때만 발동. pub struct VariableFractionInListRule; +static META_VARIABLEFRACTIONINLISTRULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "7", + subsection: None, + name: "math_variable_fraction", + standard_ref: "2024 Korean Braille Standard, 수학 제7항", + description: "나열 속 변수 분수", +}; + impl MathTokenRule for VariableFractionInListRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_VARIABLEFRACTIONINLISTRULE + } + fn name(&self) -> &'static str { "VariableFractionInListRule" } @@ -255,7 +291,19 @@ impl MathTokenRule for VariableFractionInListRule { pub struct ConditionalProbFractionRule; +static META_CONDITIONALPROBFRACTIONRULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "7", + subsection: None, + name: "math_conditional_fraction", + standard_ref: "2024 Korean Braille Standard, 수학 제7항", + description: "조건부 확률 분수", +}; + impl MathTokenRule for ConditionalProbFractionRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_CONDITIONALPROBFRACTIONRULE + } + fn name(&self) -> &'static str { "ConditionalProbFractionRule" } diff --git a/libs/braillify/src/rules/math/rule_8.rs b/libs/braillify/src/rules/math/rule_8.rs index f7e1b453..9944ccee 100644 --- a/libs/braillify/src/rules/math/rule_8.rs +++ b/libs/braillify/src/rules/math/rule_8.rs @@ -66,7 +66,19 @@ pub fn encode_decimal_point( pub struct DecimalPointRule; +static META_DECIMALPOINTRULE: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "8", + subsection: None, + name: "math_decimal_point", + standard_ref: "2024 Korean Braille Standard, 수학 제8항", + description: "소수점", +}; + impl MathTokenRule for DecimalPointRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META_DECIMALPOINTRULE + } + fn name(&self) -> &'static str { "DecimalPointRule" } diff --git a/libs/braillify/src/rules/mod.rs b/libs/braillify/src/rules/mod.rs index 82ab12c3..674639dd 100644 --- a/libs/braillify/src/rules/mod.rs +++ b/libs/braillify/src/rules/mod.rs @@ -29,6 +29,7 @@ pub mod token; pub mod token_engine; pub mod token_rule; pub mod token_rules; +pub mod trace; pub mod traits; // ── Rule domains ──────────────────────────────────────── diff --git a/libs/braillify/src/rules/token_engine.rs b/libs/braillify/src/rules/token_engine.rs index 2fe12d7c..a04ba0b2 100644 --- a/libs/braillify/src/rules/token_engine.rs +++ b/libs/braillify/src/rules/token_engine.rs @@ -1,6 +1,8 @@ +use super::RuleMeta; use super::context::EncoderState; use super::token::Token; use super::token_rule::{TokenAction, TokenPhase, TokenRule}; +use super::trace::{RuleId, TokenOrigins}; pub struct TokenRuleEngine { rules: Vec>, @@ -27,11 +29,28 @@ impl TokenRuleEngine { } } + /// Metadata of every registered token rule, in [`RuleId`] order. + pub(crate) fn registry(&mut self) -> Vec<&'static RuleMeta> { + self.ensure_sorted(); + self.rules.iter().map(|rule| rule.meta()).collect() + } + /// Apply all rules in phase order. Handle token insertions/removals correctly. + #[cfg(test)] pub fn apply_all<'a>( &mut self, tokens: &mut Vec>, state: &mut EncoderState, + ) -> Result<(), String> { + self.apply_all_tracked(tokens, state, None) + } + + /// [`Self::apply_all`], recording which rule produced each resulting token. + pub fn apply_all_tracked<'a>( + &mut self, + tokens: &mut Vec>, + state: &mut EncoderState, + mut origins: Option<&mut TokenOrigins>, ) -> Result<(), String> { self.ensure_sorted(); @@ -46,7 +65,7 @@ impl TokenRuleEngine { let mut i = 0usize; 'outer: while i < tokens.len() { - for rule in &self.rules { + for (rule_index, rule) in self.rules.iter().enumerate() { if rule.phase() != phase { continue; } @@ -57,19 +76,29 @@ impl TokenRuleEngine { if is_noop_fallthrough { continue; } + let id = RuleId::token(rule_index); match action { TokenAction::Noop => {} TokenAction::Replace(t) => { tokens[i] = t; + if let Some(origins) = origins.as_deref_mut() { + origins.set(i, id); + } } #[cfg(test)] TokenAction::InsertBefore(ts) => { let count = ts.len(); + if let Some(origins) = origins.as_deref_mut() { + origins.splice(i..i, id, count); + } tokens.splice(i..i, ts); i += count; } TokenAction::ReplaceMany(ts) => { let count = ts.len(); + if let Some(origins) = origins.as_deref_mut() { + origins.splice(i..i + 1, id, count); + } tokens.splice(i..=i, ts); if count == 0 { // Array shrank by 1: the next original token now sits at `i`. @@ -84,6 +113,9 @@ impl TokenRuleEngine { // 현재 위치 i부터 consume_count개의 토큰을 통째로 ts로 교체한다. let end = (i + consume_count).min(tokens.len()); let new_count = ts.len(); + if let Some(origins) = origins.as_deref_mut() { + origins.splice(i..end, id, new_count); + } tokens.splice(i..end, ts); if new_count == 0 { continue 'outer; @@ -93,9 +125,17 @@ impl TokenRuleEngine { #[cfg(test)] TokenAction::Remove => { tokens.remove(i); + if let Some(origins) = origins.as_deref_mut() { + origins.remove(i); + } continue; } } + debug_assert_eq!( + origins.as_deref().map_or(tokens.len(), TokenOrigins::len), + tokens.len(), + "origin tracking must stay in lockstep with the token stream" + ); break; } i += 1; diff --git a/libs/braillify/src/rules/token_rule.rs b/libs/braillify/src/rules/token_rule.rs index fa291a03..210c14fd 100644 --- a/libs/braillify/src/rules/token_rule.rs +++ b/libs/braillify/src/rules/token_rule.rs @@ -1,6 +1,18 @@ +use super::RuleMeta; use super::context::EncoderState; use super::token::Token; +/// Placeholder for a token rule that has not declared its source article yet. +/// Rules keeping this default are reported as unattributed rather than being +/// credited to an article nobody checked against the standard. +pub static UNDECLARED_TOKEN_RULE: RuleMeta = RuleMeta { + section: "?", + subsection: None, + name: "undeclared_token_rule", + standard_ref: "", + description: "", +}; + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum TokenPhase { Normalization = 0, @@ -25,6 +37,13 @@ pub enum TokenAction<'a> { } pub trait TokenRule: Send + Sync { + /// The standard article this rule implements. Defaults to + /// [`UNDECLARED_TOKEN_RULE`] so a rule is reported as unattributed until + /// someone checks its article against the PDF. + fn meta(&self) -> &'static RuleMeta { + &UNDECLARED_TOKEN_RULE + } + fn phase(&self) -> TokenPhase; fn priority(&self) -> u16 { 100 diff --git a/libs/braillify/src/rules/token_rules/digital_notation.rs b/libs/braillify/src/rules/token_rules/digital_notation.rs index 2bbd027a..f2646dff 100644 --- a/libs/braillify/src/rules/token_rules/digital_notation.rs +++ b/libs/braillify/src/rules/token_rules/digital_notation.rs @@ -19,7 +19,19 @@ static DIGITAL_INITIAL_PRON_RULE: LazyLock pub struct DigitalNotationRule; +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "?", + subsection: None, + name: "undeclared_digital_notation", + standard_ref: "", + description: "숫자·기호가 섞인 디지털 표기 처리", +}; + impl TokenRule for DigitalNotationRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { TokenPhase::ModeEntry } diff --git a/libs/braillify/src/rules/token_rules/emphasis_ring.rs b/libs/braillify/src/rules/token_rules/emphasis_ring.rs index 7275ec6d..32b4f9a4 100644 --- a/libs/braillify/src/rules/token_rules/emphasis_ring.rs +++ b/libs/braillify/src/rules/token_rules/emphasis_ring.rs @@ -1,11 +1,20 @@ use std::borrow::Cow; +use crate::rules::RuleMeta; use crate::rules::token::{Token, WordToken}; use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; use crate::unicode::decode_unicode; pub struct EmphasisRingRule; +static META: RuleMeta = RuleMeta { + section: "56", + subsection: None, + name: "token_emphasis_ring", + standard_ref: "2024 Korean Braille Standard, 제56항", + description: "Normalize combining marks used for Korean emphasis", +}; + /// 드러냄표(제56항)에 쓰이는 결합 부호. /// - U+030A `◌̊`(combining ring above): 「훈민정음̊」 등 PDF 예시 /// - U+0307 `◌̇`(combining dot above): 한국어 본문에서 강조용으로 쓰이는 결합 부호 @@ -36,6 +45,10 @@ fn trim_ring_marks(text: &str) -> String { } impl TokenRule for EmphasisRingRule { + fn meta(&self) -> &'static RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { TokenPhase::Normalization } diff --git a/libs/braillify/src/rules/token_rules/english_dominant_korean_wrap.rs b/libs/braillify/src/rules/token_rules/english_dominant_korean_wrap.rs index ad76baa5..45aadd45 100644 --- a/libs/braillify/src/rules/token_rules/english_dominant_korean_wrap.rs +++ b/libs/braillify/src/rules/token_rules/english_dominant_korean_wrap.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; +use crate::rules::RuleMeta; use crate::rules::context::DocumentSummary; use crate::rules::token::{Token, WordMeta, WordToken}; use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; @@ -28,6 +29,14 @@ const HANGUL_WRAP_END: [u8; 2] = [56, 62]; // ⠸⠾ — 한글 종료표 (제39 pub struct EnglishDominantKoreanWrapRule; +static META: RuleMeta = RuleMeta { + section: "39", + subsection: None, + name: "english_dominant_korean_wrap", + standard_ref: "2024 Korean Braille Standard, 제39항", + description: "Wrap Korean segments embedded between English words", +}; + fn build_word_token<'a>(text: &str) -> Token<'a> { let chars: Vec = text.chars().collect(); @@ -431,6 +440,10 @@ fn build_wrapped_replacement<'a>( } impl TokenRule for EnglishDominantKoreanWrapRule { + fn meta(&self) -> &'static RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { // PostWord 단계는 fall-through(Noop이면 다음 룰 시도) 지원이라 // 다른 PostWord 룰들과 협력 가능하며, 다른 ModeEntry 변환(digital_notation diff --git a/libs/braillify/src/rules/token_rules/historical_gloss_spacing.rs b/libs/braillify/src/rules/token_rules/historical_gloss_spacing.rs index f2974877..e16c5550 100644 --- a/libs/braillify/src/rules/token_rules/historical_gloss_spacing.rs +++ b/libs/braillify/src/rules/token_rules/historical_gloss_spacing.rs @@ -3,7 +3,19 @@ use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; pub struct HistoricalGlossSpacingRule; +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "?", + subsection: None, + name: "undeclared_historical_gloss_spacing", + standard_ref: "", + description: "한자 음독 주석 주변 띄어쓰기 조정", +}; + impl TokenRule for HistoricalGlossSpacingRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { TokenPhase::Normalization } diff --git a/libs/braillify/src/rules/token_rules/inline_fraction.rs b/libs/braillify/src/rules/token_rules/inline_fraction.rs index 44e92bdb..d69716fa 100644 --- a/libs/braillify/src/rules/token_rules/inline_fraction.rs +++ b/libs/braillify/src/rules/token_rules/inline_fraction.rs @@ -10,7 +10,19 @@ static FRACTION_REGEX: Lazy = pub struct InlineFractionRule; +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "?", + subsection: None, + name: "undeclared_inline_fraction", + standard_ref: "", + description: "본문 속 N/N 표기를 분수 토큰으로 변환", +}; + impl TokenRule for InlineFractionRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { TokenPhase::FractionDetection } diff --git a/libs/braillify/src/rules/token_rules/latex_fraction.rs b/libs/braillify/src/rules/token_rules/latex_fraction.rs index 30c7f1dd..f4925d8c 100644 --- a/libs/braillify/src/rules/token_rules/latex_fraction.rs +++ b/libs/braillify/src/rules/token_rules/latex_fraction.rs @@ -4,7 +4,19 @@ use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; pub struct LatexFractionRule; +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "?", + subsection: None, + name: "undeclared_latex_fraction", + standard_ref: "", + description: "LaTeX \\frac{}{} 표기를 분수 토큰으로 변환", +}; + impl TokenRule for LatexFractionRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { TokenPhase::FractionDetection } diff --git a/libs/braillify/src/rules/token_rules/latex_math/merge_rule.rs b/libs/braillify/src/rules/token_rules/latex_math/merge_rule.rs index fdfe7a2e..b9e63670 100644 --- a/libs/braillify/src/rules/token_rules/latex_math/merge_rule.rs +++ b/libs/braillify/src/rules/token_rules/latex_math/merge_rule.rs @@ -9,7 +9,19 @@ use super::math_context_from_state; pub struct LatexMergeRule; +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "?", + subsection: None, + name: "undeclared_latex_merge", + standard_ref: "", + description: "공백으로 끊긴 $...$ 수식 구간을 하나로 합침", +}; + impl TokenRule for LatexMergeRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { TokenPhase::Normalization } diff --git a/libs/braillify/src/rules/token_rules/math_expression.rs b/libs/braillify/src/rules/token_rules/math_expression.rs index 6786721a..fa71fd78 100644 --- a/libs/braillify/src/rules/token_rules/math_expression.rs +++ b/libs/braillify/src/rules/token_rules/math_expression.rs @@ -4,17 +4,30 @@ //! function names, superscript/subscript chars, etc.) and encodes them //! using the math braille engine instead of Korean character rules. +use crate::rules::RuleMeta; use crate::rules::context::EncoderState; use crate::rules::token::Token; use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; pub struct MathExpressionTokenRule; +static META: RuleMeta = RuleMeta { + section: "11", + subsection: None, + name: "math_expression_token", + standard_ref: "2024 Korean Braille Standard, 수학 제11항", + description: "Detect and encode mathematical expressions embedded in text", +}; + mod apply; mod detect; mod helpers; impl TokenRule for MathExpressionTokenRule { + fn meta(&self) -> &'static RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { TokenPhase::FractionDetection } diff --git a/libs/braillify/src/rules/token_rules/middle_dot_spacing.rs b/libs/braillify/src/rules/token_rules/middle_dot_spacing.rs index 323817de..7e2ce645 100644 --- a/libs/braillify/src/rules/token_rules/middle_dot_spacing.rs +++ b/libs/braillify/src/rules/token_rules/middle_dot_spacing.rs @@ -1,10 +1,19 @@ use std::borrow::Cow; +use crate::rules::RuleMeta; use crate::rules::token::{Token, WordMeta, WordToken}; use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; pub struct MiddleDotSpacingRule; +static META_MIDDLE_DOT: RuleMeta = RuleMeta { + section: "49", + subsection: None, + name: "middle_dot_spacing", + standard_ref: "2024 Korean Braille Standard, 제49항", + description: "Join Korean words around a middle dot according to print spacing", +}; + fn previous_word<'a, 'b>(tokens: &'b [Token<'a>], index: usize) -> Option<&'b WordToken<'a>> { tokens[..index] .iter() @@ -68,6 +77,10 @@ fn space_precedes_korean_colon_or_semicolon( } impl TokenRule for MiddleDotSpacingRule { + fn meta(&self) -> &'static RuleMeta { + &META_MIDDLE_DOT + } + fn phase(&self) -> TokenPhase { TokenPhase::PostWord } @@ -219,6 +232,10 @@ fn owned_word<'a>(chars: &[char]) -> Token<'a> { } impl TokenRule for KoreanSemicolonTrailingSpaceRule { + fn meta(&self) -> &'static RuleMeta { + &META_SEMICOLON_SPACE + } + fn phase(&self) -> TokenPhase { TokenPhase::PostWord } @@ -261,7 +278,27 @@ impl TokenRule for KoreanSemicolonTrailingSpaceRule { /// 로마자·숫자이므로 양쪽이 모두 로마자·숫자인 자리만 띄운 채로 둔다. pub struct KoreanHyphenSpacingRule; +static META_SEMICOLON_SPACE: RuleMeta = RuleMeta { + section: "59", + subsection: None, + name: "korean_semicolon_trailing_space", + standard_ref: "2024 Korean Braille Standard, 제59항", + description: "Add the standard trailing blank after a Korean semicolon", +}; + +static META_HYPHEN_SPACING: RuleMeta = RuleMeta { + section: "49", + subsection: None, + name: "korean_hyphen_spacing", + standard_ref: "2024 Korean Braille Standard, 제49항", + description: "Join Korean words around an editorial hyphen", +}; + impl TokenRule for KoreanHyphenSpacingRule { + fn meta(&self) -> &'static RuleMeta { + &META_HYPHEN_SPACING + } + fn phase(&self) -> TokenPhase { TokenPhase::PostWord } @@ -415,7 +452,19 @@ impl TokenRule for HuggingPunctuationSpacingRule { pub struct TildeSpacingRule; +static META_TILDE_SPACING: RuleMeta = RuleMeta { + section: "49", + subsection: None, + name: "korean_tilde_spacing", + standard_ref: "2024 Korean Braille Standard, 제49항", + description: "Join Korean words around a tilde according to print spacing", +}; + impl TokenRule for TildeSpacingRule { + fn meta(&self) -> &'static RuleMeta { + &META_TILDE_SPACING + } + fn phase(&self) -> TokenPhase { TokenPhase::PostWord } diff --git a/libs/braillify/src/rules/token_rules/middle_korean_detector.rs b/libs/braillify/src/rules/token_rules/middle_korean_detector.rs index 4f688e5d..3e90c9f2 100644 --- a/libs/braillify/src/rules/token_rules/middle_korean_detector.rs +++ b/libs/braillify/src/rules/token_rules/middle_korean_detector.rs @@ -72,7 +72,19 @@ fn nearest_next_word<'a>(tokens: &'a [Token<'a>], index: usize) -> Option<&'a [c None } +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "?", + subsection: None, + name: "undeclared_middle_korean_detector", + standard_ref: "", + description: "중세국어 문맥 감지 후 인코딩 모드 전환", +}; + impl TokenRule for MiddleKoreanDetectorRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { TokenPhase::Normalization } diff --git a/libs/braillify/src/rules/token_rules/normalize.rs b/libs/braillify/src/rules/token_rules/normalize.rs index 231ddd3f..7d867a70 100644 --- a/libs/braillify/src/rules/token_rules/normalize.rs +++ b/libs/braillify/src/rules/token_rules/normalize.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; +use crate::rules::RuleMeta; use crate::rules::token::{Token, WordToken}; use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; @@ -14,6 +15,14 @@ use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; /// character-local. pub struct NormalizeAsciiAngleBrackets; +static META_ASCII_ANGLE_BRACKETS: RuleMeta = RuleMeta { + section: "49", + subsection: None, + name: "normalize_ascii_angle_brackets", + standard_ref: "2024 Korean Braille Standard, 제49항", + description: "Normalize balanced ASCII angle brackets used as Korean enclosures", +}; + #[derive(Clone, Copy)] struct FlatChar { token_index: usize, @@ -112,6 +121,10 @@ fn ascii_angle_replacements(tokens: &[Token<'_>]) -> Vec<(usize, usize, char)> { } impl TokenRule for NormalizeAsciiAngleBrackets { + fn meta(&self) -> &'static RuleMeta { + &META_ASCII_ANGLE_BRACKETS + } + fn phase(&self) -> TokenPhase { TokenPhase::Normalization } @@ -160,7 +173,19 @@ impl TokenRule for NormalizeAsciiAngleBrackets { pub struct NormalizeEllipsis; +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "?", + subsection: None, + name: "undeclared_ellipsis_normalization", + standard_ref: "", + description: "말줄임표 문자를 표준 형태로 정규화", +}; + impl TokenRule for NormalizeEllipsis { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { TokenPhase::Normalization } diff --git a/libs/braillify/src/rules/token_rules/quote_attachment.rs b/libs/braillify/src/rules/token_rules/quote_attachment.rs index 39a6714d..a162eb58 100644 --- a/libs/braillify/src/rules/token_rules/quote_attachment.rs +++ b/libs/braillify/src/rules/token_rules/quote_attachment.rs @@ -55,7 +55,19 @@ fn quote_balance_before<'a>(tokens: &[Token<'a>], index: usize) -> i32 { balance } +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "?", + subsection: None, + name: "undeclared_quote_attachment", + standard_ref: "", + description: "따옴표를 앞뒤 어절에 붙여 한 토큰으로 묶음", +}; + impl TokenRule for QuoteAttachmentRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { TokenPhase::Normalization } diff --git a/libs/braillify/src/rules/token_rules/roman_numeral.rs b/libs/braillify/src/rules/token_rules/roman_numeral.rs index 54494371..15886ecc 100644 --- a/libs/braillify/src/rules/token_rules/roman_numeral.rs +++ b/libs/braillify/src/rules/token_rules/roman_numeral.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; +use crate::rules::RuleMeta; use crate::rules::token::{Token, WordMeta, WordToken}; use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; @@ -11,6 +12,14 @@ const HYPHEN: u8 = crate::unicode::decode_unicode('⠤'); pub struct RomanNumeralRule; +static META: RuleMeta = RuleMeta { + section: "36", + subsection: None, + name: "roman_numeral_token", + standard_ref: "2024 Korean Braille Standard, 제36항", + description: "Encode Roman numerals from I through XXXIX as Roman sections", +}; + fn is_upper_roman_char(c: char) -> bool { matches!(c, 'I' | 'V' | 'X') } @@ -127,6 +136,10 @@ fn encode_roman_segment(text: &str, entry: u8, with_terminator: bool) -> Result< } impl TokenRule for RomanNumeralRule { + fn meta(&self) -> &'static RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { TokenPhase::ModeEntry } diff --git a/libs/braillify/src/rules/token_rules/rule_33_citation.rs b/libs/braillify/src/rules/token_rules/rule_33_citation.rs index dbf3369a..6aacbba9 100644 --- a/libs/braillify/src/rules/token_rules/rule_33_citation.rs +++ b/libs/braillify/src/rules/token_rules/rule_33_citation.rs @@ -7,6 +7,7 @@ use crate::english::encode_english; use crate::number::encode_number; +use crate::rules::RuleMeta; use crate::rules::context::EncoderState; use crate::rules::token::Token; use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; @@ -14,6 +15,14 @@ use crate::unicode::decode_unicode; pub struct Rule33CitationYearSuffixRule; +static META: RuleMeta = RuleMeta { + section: "33", + subsection: None, + name: "rule_33_citation_year_suffix", + standard_ref: "2024 Korean Braille Standard, 제33항", + description: "Encode academic citation year suffixes as an English-mode token", +}; + /// Rule33가 emit한 PreEncoded인지 구조적으로 확인한다. /// Pattern: `⠼(60)` + 4 digit bytes + (`⠴`(52) | `⠰`(48)) + letter byte + suffix. fn is_rule33_emission(bytes: &[u8]) -> bool { @@ -67,6 +76,10 @@ fn match_year_suffix(text: &str) -> Option<(&str, char, char)> { } impl TokenRule for Rule33CitationYearSuffixRule { + fn meta(&self) -> &'static RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { // Normalization 단계 — 다른 토큰 변환 전에 처리. 토큰 엔진은 Normalization // phase에서 Noop 시에도 다음 rule을 시도하므로 안전하다. diff --git a/libs/braillify/src/rules/token_rules/rule_73_appendix_placeholder.rs b/libs/braillify/src/rules/token_rules/rule_73_appendix_placeholder.rs index 94ecd3ef..5b6a2957 100644 --- a/libs/braillify/src/rules/token_rules/rule_73_appendix_placeholder.rs +++ b/libs/braillify/src/rules/token_rules/rule_73_appendix_placeholder.rs @@ -4,6 +4,7 @@ //! 표준 prefix 시퀀스(`⠸⠦⠦⠄⠫⠠⠴⠴⠇`)를 삽입하고 사이 공백을 제거한다. //! 입력에 U+F000 자리표시자가 있는 경우에만 활성화되므로 일반 텍스트에는 영향 없음. +use crate::rules::RuleMeta; use crate::rules::context::EncoderState; use crate::rules::token::Token; use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; @@ -11,7 +12,19 @@ use crate::unicode::decode_unicode; pub struct Rule73AppendixPlaceholderRule; +static META: RuleMeta = RuleMeta { + section: "73", + subsection: Some("b1"), + name: "rule_73_appendix_placeholder", + standard_ref: "2024 Korean Braille Standard, 제73항 [붙임 1]", + description: "Insert the standard placeholder prefix for blank-marker examples", +}; + impl TokenRule for Rule73AppendixPlaceholderRule { + fn meta(&self) -> &'static RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { TokenPhase::Normalization } diff --git a/libs/braillify/src/rules/token_rules/spacing.rs b/libs/braillify/src/rules/token_rules/spacing.rs index 9f118355..e83007c5 100644 --- a/libs/braillify/src/rules/token_rules/spacing.rs +++ b/libs/braillify/src/rules/token_rules/spacing.rs @@ -1,3 +1,4 @@ +use crate::rules::RuleMeta; use crate::rules::token::Token; use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; @@ -11,7 +12,19 @@ pub struct AsteriskSpacingRule; /// rule deliberately performs no transformation. pub struct KoreanAuxiliaryVerbSpacingRule; +static META_AUXILIARY_SPACING: RuleMeta = RuleMeta { + section: "49", + subsection: None, + name: "korean_auxiliary_verb_spacing", + standard_ref: "2024 Korean Braille Standard, 제49항", + description: "Preserve Korean print spacing for auxiliary verbs", +}; + impl TokenRule for KoreanAuxiliaryVerbSpacingRule { + fn meta(&self) -> &'static RuleMeta { + &META_AUXILIARY_SPACING + } + fn phase(&self) -> TokenPhase { TokenPhase::Normalization } @@ -37,7 +50,19 @@ fn is_last_word_index(tokens: &[Token], index: usize) -> bool { .any(|t| matches!(t, Token::Word(_))) } +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "?", + subsection: None, + name: "undeclared_asterisk_spacing", + standard_ref: "", + description: "별표 앞뒤 띄어쓰기 조정", +}; + impl TokenRule for AsteriskSpacingRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { TokenPhase::PostWord } diff --git a/libs/braillify/src/rules/token_rules/uppercase_passage.rs b/libs/braillify/src/rules/token_rules/uppercase_passage.rs index 68ca0f7b..ed1ac663 100644 --- a/libs/braillify/src/rules/token_rules/uppercase_passage.rs +++ b/libs/braillify/src/rules/token_rules/uppercase_passage.rs @@ -231,7 +231,19 @@ fn is_korean_math_letter_list_start( && second_has_attached_korean } +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "?", + subsection: None, + name: "undeclared_uppercase_passage", + standard_ref: "", + description: "연속 대문자 구간을 하나의 구절로 묶음", +}; + impl TokenRule for UppercasePassageRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { TokenPhase::UppercasePassage } diff --git a/libs/braillify/src/rules/token_rules/word_shortcut.rs b/libs/braillify/src/rules/token_rules/word_shortcut.rs index 834d2d87..0e9b51b5 100644 --- a/libs/braillify/src/rules/token_rules/word_shortcut.rs +++ b/libs/braillify/src/rules/token_rules/word_shortcut.rs @@ -1,12 +1,25 @@ use std::borrow::Cow; +use crate::rules::RuleMeta; use crate::rules::token::{Token, WordMeta, WordToken}; use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; use crate::word_shortcut; pub struct WordShortcutRule; +static META: RuleMeta = RuleMeta { + section: "18", + subsection: None, + name: "token_word_shortcut", + standard_ref: "2024 Korean Braille Standard, 제18항", + description: "Apply Korean word abbreviations while preserving punctuation context", +}; + impl TokenRule for WordShortcutRule { + fn meta(&self) -> &'static RuleMeta { + &META + } + fn phase(&self) -> TokenPhase { TokenPhase::WordShortcut } diff --git a/libs/braillify/src/rules/trace.rs b/libs/braillify/src/rules/trace.rs new file mode 100644 index 00000000..925e29d8 --- /dev/null +++ b/libs/braillify/src/rules/trace.rs @@ -0,0 +1,757 @@ +//! Rule provenance — which registered rule produced which output cells. +//! +//! Tracing is opt-in. [`crate::encode`] never builds a [`Trace`]; only +//! [`crate::encode_with_trace`] passes a sink down to the rule engine, so the +//! untraced path keeps its shape. +//! +//! # What a trace can and cannot tell you +//! +//! Only the Korean character-level rule engine ([`super::engine::RuleEngine`]) +//! is instrumented. Input owned by the UEB grade-2 engine or by the math token +//! engine produces **no events at all** — [`Trace::path`] reports which engine +//! ran so that an empty event list is never mistaken for "no rule applied". +//! +//! Within the Korean engine the unit of attribution is the *registered rule*, +//! not the standard's article. `RuleKorean` covers all ordinary syllable +//! composition, so `안녕` attributes to one rule per syllable rather than to +//! 제1항/제7항 individually. Narrowing that boundary means moving the dispatch +//! one level inward, not reinterpreting the events recorded here. +//! +//! # Rules that match but do not contribute +//! +//! A rule that returns [`RuleResult::Skip`](super::traits::RuleResult::Skip) +//! after matching produced nothing, so it is **not** recorded. "The rule ran" +//! and "the rule explains this output" are different claims, and only the +//! second one is worth reporting. + +use std::ops::Range; +use std::sync::LazyLock; + +use super::RuleMeta; + +/// Identifier of a rule in one of the engines that make up the encoder. +/// +/// The id space is partitioned by engine ([`RuleKind`]); within a partition the +/// value is the rule's position in that engine's dispatch order. Deriving ids +/// from the engines themselves (rather than from a hand-written table) means the +/// id space cannot drift away from the rules that can actually fire: +/// `rules/korean/` declares roughly twice as many [`RuleMeta`] literals as the +/// character engine registers, and the rest are unreachable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RuleId(pub u16); + +/// Which engine a [`RuleId`] belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RuleKind { + /// Character-level Korean rules ([`super::traits::BrailleRule`]). + Korean, + /// Token-level rewrites run before character encoding + /// ([`super::token_rule::TokenRule`]). + Token, + /// Math expression rules ([`super::math::math_token_rule::MathTokenRule`]). + Math, + /// Jamo-level articles applied while composing one Korean syllable + /// ([`JamoRule`]). These are code paths inside `korean_char`, not registered + /// rule objects, so they carry their own metadata. + Jamo, + /// UEB grade-2 contraction rules + /// ([`super::english_ueb::contraction::ContractionRule`]). + EnglishUeb, + /// Cells the emitter writes directly, outside any rule object. + Emitter, +} + +/// The article behind each step of composing one Korean syllable. +/// +/// `RuleKorean` is a single registered rule covering all ordinary syllables, so +/// without this split every syllable reports the same composite entry. Sections +/// are taken from the PDF-derived fixtures in `test_cases/korean/`: `rule_6.json` +/// holds 아/야/어/여/오/요/우/유 and `rule_7.json` holds ㅐ/ㅒ/ㅔ/ㅖ/ㅘ/ㅙ, which +/// is what separates the two vowel articles. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JamoRule { + /// 제1항 — 초성 자음자. + Choseong = 0, + /// 제2항 — 된소리 첫소리 표. + DoubleChoseong = 1, + /// 제3항 — 받침 자음자. + Jongseong = 2, + /// 제6항 — 기본 모음자. + Jungseong = 3, + /// 제7항 — 그 밖의 모음자. + JungseongExtended = 4, + /// 제13항 — 글자 약자. + Shortcut = 5, +} + +impl JamoRule { + const ALL: [Self; 6] = [ + Self::Choseong, + Self::DoubleChoseong, + Self::Jongseong, + Self::Jungseong, + Self::JungseongExtended, + Self::Shortcut, + ]; + + /// 제6항's ten basic vowels, listed in `test_cases/korean/rule_6.json`; + /// every other vowel belongs to 제7항. + const BASIC_VOWELS: [char; 10] = ['ㅏ', 'ㅑ', 'ㅓ', 'ㅕ', 'ㅗ', 'ㅛ', 'ㅜ', 'ㅠ', 'ㅡ', 'ㅣ']; + + pub(crate) fn for_vowel(jung: char) -> Self { + if Self::BASIC_VOWELS.contains(&jung) { + Self::Jungseong + } else { + Self::JungseongExtended + } + } + + fn meta(self) -> &'static RuleMeta { + &JAMO_METAS[self as usize] + } +} + +static JAMO_METAS: [RuleMeta; 6] = [ + RuleMeta { + section: "1", + subsection: None, + name: "syllable_choseong", + standard_ref: "2024 Korean Braille Standard, 제1항", + description: "음절 첫소리 자음자", + }, + RuleMeta { + section: "2", + subsection: None, + name: "syllable_double_choseong", + standard_ref: "2024 Korean Braille Standard, 제2항", + description: "된소리 첫소리 표", + }, + RuleMeta { + section: "3", + subsection: None, + name: "syllable_jongseong", + standard_ref: "2024 Korean Braille Standard, 제3항", + description: "음절 받침 자음자", + }, + RuleMeta { + section: "6", + subsection: None, + name: "syllable_jungseong", + standard_ref: "2024 Korean Braille Standard, 제6항", + description: "기본 모음자", + }, + RuleMeta { + section: "7", + subsection: None, + name: "syllable_jungseong_extended", + standard_ref: "2024 Korean Braille Standard, 제7항", + description: "그 밖의 모음자", + }, + RuleMeta { + section: "13", + subsection: None, + name: "syllable_shortcut", + standard_ref: "2024 Korean Braille Standard, 제13항", + description: "글자 약자", + }, +]; + +impl RuleId { + const TOKEN_BASE: u16 = 1000; + const MATH_BASE: u16 = 2000; + const JAMO_BASE: u16 = 3000; + const UEB_BASE: u16 = 4000; + const EMITTER_BASE: u16 = 5000; + + /// A rule with no registry entry. + pub const UNATTRIBUTED: Self = Self(u16::MAX); + + pub(crate) fn korean(index: usize) -> Self { + Self::within(0, Self::TOKEN_BASE, index) + } + + pub(crate) fn token(index: usize) -> Self { + Self::within(Self::TOKEN_BASE, Self::MATH_BASE, index) + } + + pub(crate) fn math(index: usize) -> Self { + Self::within(Self::MATH_BASE, Self::JAMO_BASE, index) + } + + pub(crate) fn jamo(slot: JamoRule) -> Self { + Self(Self::JAMO_BASE + slot as u16) + } + + pub(crate) fn ueb(index: usize) -> Self { + Self::within(Self::UEB_BASE, Self::EMITTER_BASE, index) + } + + pub(crate) fn emitter(slot: EmitterRule) -> Self { + Self(Self::EMITTER_BASE + slot as u16) + } + + fn within(base: u16, limit: u16, index: usize) -> Self { + u16::try_from(index) + .ok() + .and_then(|i| base.checked_add(i)) + .filter(|id| *id < limit) + .map_or(Self::UNATTRIBUTED, Self) + } + + /// Which engine this id belongs to, or `None` for [`Self::UNATTRIBUTED`]. + pub fn kind(self) -> Option { + match self.0 { + _ if self == Self::UNATTRIBUTED => None, + id if id < Self::TOKEN_BASE => Some(RuleKind::Korean), + id if id < Self::MATH_BASE => Some(RuleKind::Token), + id if id < Self::JAMO_BASE => Some(RuleKind::Math), + id if id < Self::UEB_BASE => Some(RuleKind::Jamo), + id if id < Self::EMITTER_BASE => Some(RuleKind::EnglishUeb), + _ => Some(RuleKind::Emitter), + } + } + + /// Metadata for this rule, or `None` for [`Self::UNATTRIBUTED`]. + pub fn meta(self) -> Option<&'static RuleMeta> { + rule_meta(self) + } +} + +/// How a recorded rule ended its dispatch. +/// +/// [`RuleResult::Skip`](super::traits::RuleResult::Skip) has no variant here — +/// a skipping rule contributed nothing and is never recorded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuleOutcome { + /// The rule fully handled the character; no later rule ran for it. + Consumed, + /// The rule contributed and let later rules run for the same character. + Continued, +} + +/// One rule dispatch that contributed to the output. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TraceEvent { + /// The rule that ran. Resolve with [`RuleId::meta`]. + pub rule: RuleId, + /// How the dispatch ended. + pub outcome: RuleOutcome, + /// Index into the token stream **as it exists after token rules ran**. + /// Token rules rewrite the stream, so this does not index the input text. + pub token_index: u32, + /// Character range consumed within the current word, word-local. + pub word_chars: Range, + /// Cell range produced in the final output. Exact, and the anchor a + /// consumer should key on. An empty range means the rule changed encoder + /// state (mode, number context) without emitting cells. + pub output: Range, +} + +/// Which engine produced the output, and therefore how far the events reach. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TracePath { + /// The Korean character-rule engine ran. Events describe its dispatches. + #[default] + KoreanRules, + /// The UEB grade-2 engine owned the input. Events identify contraction rules + /// selected by its cell-minimising path. + EnglishUeb, + /// The math token engine owned the input. Not instrumented; `events` is + /// empty. + MathExpression, +} + +/// Rule dispatches recorded during one encode. +/// +/// Events never account for the whole output. Cells emitted by token-level +/// rules — 약자 abbreviations, fractions, mode indicators — bypass the character +/// engine entirely, so `그래서` encodes to two cells with **zero** events. +/// [`Self::attributed_cells`] against [`Self::output_len`] states how much of +/// the output the events actually explain, so that gap is a number a caller can +/// check rather than a silence they have to interpret. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Trace { + events: Vec, + path: TracePath, + output_len: u32, +} + +/// Cells the emitter writes itself, with no rule object behind them. +/// +/// These are structural: the emitter, not a rule, decides where an inter-word +/// blank goes. They are listed so those cells are attributed rather than +/// appearing as an unexplained gap in the output. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EmitterRule { + /// The blank cell between two print words. + WordSpace = 0, + /// A pre-encoded run a token rule produced but whose rule is undeclared. + UndeclaredTokenOutput = 1, + /// 제29항 로마자표/연속표/종료표. The emitter opens, resumes and closes a + /// Roman section from the token stream, so no character rule sees it. + RomanSectionMarker = 2, +} + +impl EmitterRule { + const ALL: [Self; 3] = [ + Self::WordSpace, + Self::UndeclaredTokenOutput, + Self::RomanSectionMarker, + ]; + + fn meta(self) -> &'static RuleMeta { + match self { + Self::WordSpace => &WORD_SPACE_META, + Self::UndeclaredTokenOutput => &UNDECLARED_TOKEN_OUTPUT_META, + Self::RomanSectionMarker => &ROMAN_SECTION_MARKER_META, + } + } +} + +static ROMAN_SECTION_MARKER_META: RuleMeta = RuleMeta { + section: "29", + subsection: None, + name: "roman_section_marker", + standard_ref: "2024 Korean Braille Standard, 제29항", + description: "로마자표·로마자 종료표", +}; + +/// The id of a registered Korean character rule, found by its metadata name. +/// +/// The emitter sometimes produces cells on behalf of a rule that also exists in +/// the engine, and this keeps both reporting the same id instead of minting a +/// second one for the same article. +pub(crate) fn korean_rule_id(name: &str) -> RuleId { + REGISTRIES + .korean + .iter() + .position(|meta| meta.name == name) + .map_or(RuleId::UNATTRIBUTED, RuleId::korean) +} + +static WORD_SPACE_META: RuleMeta = RuleMeta { + section: "-", + subsection: None, + name: "word_space", + standard_ref: "어절 사이 빈칸", + description: "Inter-word blank cell written by the emitter", +}; + +static UNDECLARED_TOKEN_OUTPUT_META: RuleMeta = RuleMeta { + section: "?", + subsection: None, + name: "undeclared_token_output", + standard_ref: "", + description: "Cells from a token rule that has not declared its article", +}; + +/// Which rule produced each token, maintained alongside the token stream. +/// +/// Token rules rewrite the stream rather than emitting cells, so their output +/// only becomes cells later in [`super::emit`]. Recording the producing rule per +/// token slot is what lets those cells be attributed at emit time. The vector is +/// kept the same length as the token stream; [`Self::len`] is asserted against +/// it after every rewrite. +#[derive(Debug, Default)] +pub struct TokenOrigins { + origins: Vec>, +} + +impl TokenOrigins { + pub(crate) fn seeded(len: usize) -> Self { + Self { + origins: vec![None; len], + } + } + + pub(crate) fn len(&self) -> usize { + self.origins.len() + } + + pub(crate) fn get(&self, index: usize) -> Option { + self.origins.get(index).copied().flatten() + } + + pub(crate) fn set(&mut self, index: usize, rule: RuleId) { + if let Some(slot) = self.origins.get_mut(index) { + *slot = Some(rule); + } + } + + pub(crate) fn splice(&mut self, range: core::ops::Range, rule: RuleId, count: usize) { + let end = range.end.min(self.origins.len()); + let start = range.start.min(end); + self.origins.splice(start..end, vec![Some(rule); count]); + } + + #[cfg(test)] + pub(crate) fn remove(&mut self, index: usize) { + if index < self.origins.len() { + self.origins.remove(index); + } + } +} + +/// A [`Trace`] bound to the token currently being emitted. +/// +/// The token index lives here rather than on +/// [`RuleContext`](super::context::RuleContext) so that the twenty-odd places +/// that build a context — nearly all of them rule unit tests — stay untouched. +pub struct TraceSink<'a> { + pub(crate) trace: &'a mut Trace, + pub(crate) token_index: u32, +} + +impl<'a> TraceSink<'a> { + pub(crate) fn new(trace: &'a mut Trace) -> Self { + Self { + trace, + token_index: 0, + } + } + + pub(crate) fn at_token(&mut self, token_index: usize) -> TraceSink<'_> { + TraceSink { + trace: self.trace, + token_index: token_index as u32, + } + } + + pub(crate) fn reborrow(&mut self) -> TraceSink<'_> { + TraceSink { + trace: self.trace, + token_index: self.token_index, + } + } + + pub(crate) fn token_index(&self) -> u32 { + self.token_index + } + + pub(crate) fn record_span( + &mut self, + rule: RuleId, + token_index: usize, + output: core::ops::Range, + ) { + self.trace.push(TraceEvent { + rule, + outcome: RuleOutcome::Consumed, + token_index: token_index as u32, + word_chars: 0..0, + output: output.start as u32..output.end as u32, + }); + } +} + +impl Trace { + /// Every recorded dispatch, in the order it happened. + pub fn events(&self) -> &[TraceEvent] { + &self.events + } + + /// Which engine produced the output. Read this before concluding anything + /// from an empty [`Self::events`]. + pub fn path(&self) -> TracePath { + self.path + } + + /// Total cells in the encoded output. + pub fn output_len(&self) -> u32 { + self.output_len + } + + /// How many output cells at least one event accounts for. + pub fn attributed_cells(&self) -> u32 { + let mut covered = vec![false; self.output_len as usize]; + for event in &self.events { + for cell in event.output.clone() { + if let Some(slot) = covered.get_mut(cell as usize) { + *slot = true; + } + } + } + covered.iter().filter(|seen| **seen).count() as u32 + } + + /// Output cells no event accounts for. A non-zero value means part of the + /// output came from an uninstrumented path, not that it came from nowhere. + pub fn unattributed_cells(&self) -> u32 { + self.output_len - self.attributed_cells() + } + + /// Rules that emitted at least one cell, in order of first contribution, + /// without repeats. + pub fn contributing_rules(&self) -> Vec { + let mut seen = Vec::new(); + for event in &self.events { + if !event.output.is_empty() && !seen.contains(&event.rule) { + seen.push(event.rule); + } + } + seen + } + + /// The rules whose output covers `cell`, innermost dispatch last. + pub fn rules_at_cell(&self, cell: u32) -> Vec { + self.events + .iter() + .filter(|event| event.output.contains(&cell)) + .map(|event| event.rule) + .collect() + } + + pub(crate) fn set_path(&mut self, path: TracePath) { + self.path = path; + } + + pub(crate) fn set_output_len(&mut self, len: u32) { + self.output_len = len; + } + + pub(crate) fn push(&mut self, event: TraceEvent) { + self.events.push(event); + } + + /// Number of events recorded so far, for [`Self::rollback_to`]. + pub(crate) fn mark(&self) -> usize { + self.events.len() + } + + /// Drop everything recorded after `mark`. + /// + /// The math pipeline encodes speculatively and falls back to the Korean + /// encoder on error, having already emitted cells for the tokens it did + /// consume. Those cells never ship, so crediting their rules would name + /// rules that did not produce the output. + pub(crate) fn rollback_to(&mut self, mark: usize) { + self.events.truncate(mark); + } + + /// Shift every output range by `delta` cells. + /// + /// 제37항 wraps an isolated Roman section by inserting the Roman indicator + /// at index 0 after encoding, which moves every cell already recorded. + pub(crate) fn shift_output(&mut self, delta: u32) { + for event in &mut self.events { + event.output = (event.output.start + delta)..(event.output.end + delta); + } + } +} + +/// Metadata of every rule the Korean engine registers, indexed by [`RuleId`]. +/// +/// Built from a throwaway [`crate::encoder::Encoder`] so the registry *is* the +/// registration list in `Encoder::new`, by construction. +struct Registries { + korean: Vec<&'static RuleMeta>, + token: Vec<&'static RuleMeta>, +} + +static REGISTRIES: LazyLock = LazyLock::new(|| { + let mut probe = crate::encoder::Encoder::new(false); + Registries { + korean: probe.char_rule_registry(), + token: probe.token_rule_registry(), + } +}); + +static MATH_REGISTRY: LazyLock> = + LazyLock::new(crate::rules::math::encoder::math_rule_registry); + +static UEB_REGISTRY: LazyLock> = + LazyLock::new(crate::rules::english_ueb::ueb_rule_registry); + +/// Metadata for `id`, or `None` when the id has no registry entry. +pub fn rule_meta(id: RuleId) -> Option<&'static RuleMeta> { + let offset = |base: u16| (id.0 - base) as usize; + match id.kind()? { + RuleKind::Korean => REGISTRIES.korean.get(id.0 as usize).copied(), + RuleKind::Token => REGISTRIES.token.get(offset(RuleId::TOKEN_BASE)).copied(), + RuleKind::Math => MATH_REGISTRY.get(offset(RuleId::MATH_BASE)).copied(), + RuleKind::Jamo => JamoRule::ALL + .get(offset(RuleId::JAMO_BASE)) + .map(|slot| slot.meta()), + RuleKind::EnglishUeb => UEB_REGISTRY.get(offset(RuleId::UEB_BASE)).copied(), + RuleKind::Emitter => EmitterRule::ALL + .get(offset(RuleId::EMITTER_BASE)) + .map(|slot| slot.meta()), + } +} + +/// Every rule that can fire, grouped by the engine it belongs to. +pub fn registered_rules(kind: RuleKind) -> &'static [&'static RuleMeta] { + match kind { + RuleKind::Korean => ®ISTRIES.korean, + RuleKind::Token => ®ISTRIES.token, + RuleKind::Math => &MATH_REGISTRY, + RuleKind::Jamo => &JAMO_METAS_REFS, + RuleKind::EnglishUeb => &UEB_REGISTRY, + RuleKind::Emitter => &[], + } +} + +static JAMO_METAS_REFS: [&RuleMeta; 6] = [ + &JAMO_METAS[0], + &JAMO_METAS[1], + &JAMO_METAS[2], + &JAMO_METAS[3], + &JAMO_METAS[4], + &JAMO_METAS[5], +]; + +#[cfg(test)] +mod tests { + use super::*; + + fn event(rule: u16, output: Range) -> TraceEvent { + TraceEvent { + rule: RuleId(rule), + outcome: RuleOutcome::Consumed, + token_index: 0, + word_chars: 0..1, + output, + } + } + + #[rstest::rstest] + #[case::korean(RuleKind::Korean)] + #[case::token(RuleKind::Token)] + #[case::math(RuleKind::Math)] + fn every_engine_registers_rules_resolvable_by_id(#[case] kind: RuleKind) { + let rules = registered_rules(kind); + assert!(!rules.is_empty(), "{kind:?} registers rules"); + + let first = match kind { + RuleKind::Korean => RuleId::korean(0), + RuleKind::Token => RuleId::token(0), + RuleKind::Math => RuleId::math(0), + RuleKind::Jamo | RuleKind::EnglishUeb | RuleKind::Emitter => { + unreachable!("only registry-backed engines are cased here") + } + }; + assert_eq!(first.kind(), Some(kind)); + assert_eq!(rule_meta(first), Some(rules[0])); + } + + /// The UEB partition reserves its first slots for move sources that are not + /// rule objects, so a contraction rule's id sits at a fixed offset. + #[test] + fn ueb_partition_reserves_slots_before_the_contraction_rules() { + let rules = registered_rules(RuleKind::EnglishUeb); + + assert!(rules.len() > crate::rules::english_ueb::UEB_RESERVED_SLOTS); + assert_eq!(RuleId::ueb(0).meta().map(|m| m.name), Some("ueb_shortform")); + assert_eq!( + RuleId::ueb(crate::rules::english_ueb::UEB_RESERVED_SLOTS) + .meta() + .map(|m| m.section), + Some("10.3"), + "the first contraction rule follows the reserved slots" + ); + } + + #[test] + fn unattributed_has_no_metadata_and_no_kind() { + assert_eq!(RuleId::UNATTRIBUTED.meta(), None); + assert_eq!(RuleId::UNATTRIBUTED.kind(), None); + } + + #[rstest::rstest] + #[case::word_space(EmitterRule::WordSpace, "word_space")] + #[case::undeclared(EmitterRule::UndeclaredTokenOutput, "undeclared_token_output")] + fn emitter_slots_resolve_to_their_metadata(#[case] slot: EmitterRule, #[case] name: &str) { + let id = RuleId::emitter(slot); + assert_eq!(id.kind(), Some(RuleKind::Emitter)); + assert_eq!(id.meta().map(|m| m.name), Some(name)); + } + + #[test] + fn korean_registry_holds_no_duplicate_rule_names() { + let mut names: Vec<_> = registered_rules(RuleKind::Korean) + .iter() + .map(|m| m.name) + .collect(); + let before = names.len(); + names.sort_unstable(); + names.dedup(); + assert_eq!(before, names.len(), "each registered rule name is unique"); + } + + #[test] + fn rollback_to_drops_events_recorded_after_the_mark() { + let mut trace = Trace::default(); + trace.push(event(1, 0..1)); + let mark = trace.mark(); + trace.push(event(2, 1..2)); + + trace.rollback_to(mark); + + assert_eq!(trace.events().len(), 1); + assert_eq!(trace.events()[0].rule, RuleId(1)); + } + + #[test] + fn token_origins_survive_a_splice_that_changes_length() { + let mut origins = TokenOrigins::seeded(3); + origins.set(0, RuleId::token(4)); + + origins.splice(1..2, RuleId::token(7), 3); + + assert_eq!(origins.len(), 5); + assert_eq!(origins.get(0), Some(RuleId::token(4))); + assert_eq!(origins.get(1), Some(RuleId::token(7))); + assert_eq!(origins.get(3), Some(RuleId::token(7))); + assert_eq!(origins.get(4), None); + } + + #[test] + fn contributing_rules_skips_cellless_events_and_repeats() { + let mut trace = Trace::default(); + trace.push(event(1, 0..2)); + trace.push(event(2, 2..2)); // state-only, emitted nothing + trace.push(event(1, 2..4)); // repeat of an already-listed rule + + assert_eq!(trace.contributing_rules(), vec![RuleId(1)]); + } + + #[rstest::rstest] + #[case::before_first(0, vec![RuleId(1)])] + #[case::inside_first(1, vec![RuleId(1)])] + #[case::inside_second(2, vec![RuleId(2)])] + #[case::past_the_end(9, vec![])] + fn rules_at_cell_selects_covering_events(#[case] cell: u32, #[case] expected: Vec) { + let mut trace = Trace::default(); + trace.push(event(1, 0..2)); + trace.push(event(2, 2..3)); + + assert_eq!(trace.rules_at_cell(cell), expected); + } + + #[test] + fn shift_output_moves_every_recorded_range() { + let mut trace = Trace::default(); + trace.push(event(1, 0..2)); + trace.push(event(2, 2..5)); + + trace.shift_output(1); + + assert_eq!(trace.events()[0].output, 1..3); + assert_eq!(trace.events()[1].output, 3..6); + } + + #[test] + fn default_path_is_the_korean_engine() { + assert_eq!(Trace::default().path(), TracePath::KoreanRules); + } + + #[rstest::rstest] + #[case::ueb(TracePath::EnglishUeb)] + #[case::math(TracePath::MathExpression)] + #[case::korean(TracePath::KoreanRules)] + fn set_path_records_the_engine_that_ran(#[case] path: TracePath) { + let mut trace = Trace::default(); + trace.set_path(path); + assert_eq!(trace.path(), path); + } +} diff --git a/packages/node/src/lib.rs b/packages/node/src/lib.rs index b6859b1d..f21b5dd1 100644 --- a/packages/node/src/lib.rs +++ b/packages/node/src/lib.rs @@ -17,6 +17,92 @@ pub fn translate_to_braille_font(text: &str) -> Result { braillify::encode_to_braille_font(text) } +/// One rule that produced part of the braille output. +#[derive(Clone)] +#[wasm_bindgen(getter_with_clone)] +pub struct RuleSpan { + /// Article number of the 2024 Korean Braille Standard, `"-"` for structural + /// emitter output and `"?"` for a rule whose article is not yet declared. + pub section: String, + pub name: String, + pub description: String, + /// Which engine produced it: `korean`, `jamo`, `token`, `math`, + /// `english-ueb` or `emitter`. + pub kind: String, + pub start: u32, + pub end: u32, + pub braille: String, +} + +/// Braille output plus the rules that produced it. +#[wasm_bindgen(getter_with_clone)] +pub struct TraceResult { + pub braille: String, + pub rules: Vec, + /// Output cells at least one rule accounts for. + pub attributed: u32, + /// Total output cells. Greater than `attributed` when part of the output + /// came from an engine that is not instrumented yet — English (UEB) in + /// particular records nothing. + pub total: u32, + /// Which engine owned the input: `korean`, `english-ueb` or `math`. + pub path: String, +} + +#[wasm_bindgen(js_name = "translateToUnicodeWithTrace")] +pub fn translate_to_unicode_with_trace(text: &str) -> Result { + let (cells, trace) = braillify::encode_with_trace(text)?; + let braille = to_braille(&cells); + let rules = trace + .events() + .iter() + .filter_map(|event| { + let meta = event.rule.meta()?; + let range = event.output.start as usize..event.output.end as usize; + Some(RuleSpan { + section: meta.section.to_string(), + name: meta.name.to_string(), + description: meta.description.to_string(), + kind: kind_label(event.rule.kind()?).to_string(), + start: event.output.start, + end: event.output.end, + braille: to_braille(cells.get(range).unwrap_or_default()), + }) + }) + .collect(); + + Ok(TraceResult { + braille, + rules, + attributed: trace.attributed_cells(), + total: trace.output_len(), + path: match trace.path() { + braillify::TracePath::KoreanRules => "korean", + braillify::TracePath::EnglishUeb => "english-ueb", + braillify::TracePath::MathExpression => "math", + } + .to_string(), + }) +} + +fn kind_label(kind: braillify::RuleKind) -> &'static str { + match kind { + braillify::RuleKind::Korean => "korean", + braillify::RuleKind::Token => "token", + braillify::RuleKind::Math => "math", + braillify::RuleKind::Jamo => "jamo", + braillify::RuleKind::EnglishUeb => "english-ueb", + braillify::RuleKind::Emitter => "emitter", + } +} + +fn to_braille(cells: &[u8]) -> String { + cells + .iter() + .filter_map(|cell| char::from_u32(0x2800 + u32::from(*cell))) + .collect() +} + #[cfg(test)] mod tests { //! Native-host tests for the wasm-bindgen shim. `wasm_bindgen` macros From fca9f05ca807920eb6dab25d530e75fc5ea0c6a5 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 14:56:58 +0900 Subject: [PATCH 002/132] Wrap the trace summary the way the linter wants --- apps/landing/src/app/RuleTrace.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/landing/src/app/RuleTrace.tsx b/apps/landing/src/app/RuleTrace.tsx index 5801fb02..6f57f0ed 100644 --- a/apps/landing/src/app/RuleTrace.tsx +++ b/apps/landing/src/app/RuleTrace.tsx @@ -235,9 +235,9 @@ export function RuleTrace({ trace }: { trace: TraceSnapshot }) { {isPartial ? ( - 출력 {trace.total}칸 가운데 {trace.attributed}칸만 규칙으로 설명됩니다. - 나머지 {trace.total - trace.attributed}칸은 아직 규칙 추적이 붙지 않은 - 부분입니다. + 출력 {trace.total}칸 가운데 {trace.attributed}칸만 규칙으로 + 설명됩니다. 나머지 {trace.total - trace.attributed}칸은 아직 규칙 + 추적이 붙지 않은 부분입니다. ) : null} {emptyNotice ? ( From d36a727f4b45b15e6894b0431eb7a1cb3b7f404e Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 15:25:59 +0900 Subject: [PATCH 003/132] Cover the trace registry and the node trace binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The linux gate wants every line reached, and the tracing work left two stretches untested: the registry lookups for the jamo articles, the emitter slots and an index past its engine's partition, and the whole trace shim in the node package. The shim's per-event conversion and its two label tables are now named functions, because a match arm inlined in a closure can only be reached by finding an input that produces it — and the math path, for one, no Korean sentence reaches. --- libs/braillify/src/rules/trace.rs | 47 ++++++++++++++ packages/node/Cargo.toml | 1 + packages/node/src/lib.rs | 103 ++++++++++++++++++++++++------ 3 files changed, 132 insertions(+), 19 deletions(-) diff --git a/libs/braillify/src/rules/trace.rs b/libs/braillify/src/rules/trace.rs index 925e29d8..fd91435a 100644 --- a/libs/braillify/src/rules/trace.rs +++ b/libs/braillify/src/rules/trace.rs @@ -660,12 +660,59 @@ mod tests { #[rstest::rstest] #[case::word_space(EmitterRule::WordSpace, "word_space")] #[case::undeclared(EmitterRule::UndeclaredTokenOutput, "undeclared_token_output")] + #[case::roman_section(EmitterRule::RomanSectionMarker, "roman_section_marker")] fn emitter_slots_resolve_to_their_metadata(#[case] slot: EmitterRule, #[case] name: &str) { let id = RuleId::emitter(slot); assert_eq!(id.kind(), Some(RuleKind::Emitter)); assert_eq!(id.meta().map(|m| m.name), Some(name)); } + /// The jamo articles are listed like any other engine's rules, while the + /// emitter's structural cells are reachable by id but are not rules anyone + /// can enumerate as candidates. + #[test] + fn the_remaining_engines_report_their_own_rule_lists() { + assert_eq!(registered_rules(RuleKind::Jamo).len(), JamoRule::ALL.len()); + assert_eq!( + registered_rules(RuleKind::Jamo)[0].section, + JamoRule::Choseong.meta().section + ); + assert!(!registered_rules(RuleKind::EnglishUeb).is_empty()); + assert!(registered_rules(RuleKind::Emitter).is_empty()); + } + + /// An index past its engine's partition would collide with the next engine, + /// so it resolves to nothing instead. + #[rstest::rstest] + #[case::korean(RuleId::korean(RuleId::TOKEN_BASE as usize))] + #[case::token(RuleId::token(RuleId::MATH_BASE as usize))] + #[case::math(RuleId::math(RuleId::JAMO_BASE as usize))] + #[case::ueb(RuleId::ueb(RuleId::EMITTER_BASE as usize))] + fn an_index_past_its_partition_resolves_to_nothing(#[case] id: RuleId) { + assert_eq!(id, RuleId::UNATTRIBUTED); + } + + /// The emitter borrows a character rule's id by name so both report the + /// same article; a name the engine never registered borrows nothing. + #[test] + fn borrowing_a_rule_id_by_name_needs_a_registered_name() { + let registered = registered_rules(RuleKind::Korean)[0].name; + + assert_eq!(korean_rule_id(registered), RuleId::korean(0)); + assert_eq!(korean_rule_id("no_such_rule"), RuleId::UNATTRIBUTED); + } + + /// 제6항 lists the ten basic vowels; every other vowel is 제7항. + #[rstest::rstest] + #[case::basic('ㅏ', JamoRule::Jungseong)] + #[case::extended('ㅘ', JamoRule::JungseongExtended)] + fn a_vowel_belongs_to_the_article_that_lists_it( + #[case] vowel: char, + #[case] expected: JamoRule, + ) { + assert_eq!(JamoRule::for_vowel(vowel), expected); + } + #[test] fn korean_registry_holds_no_duplicate_rule_names() { let mut names: Vec<_> = registered_rules(RuleKind::Korean) diff --git a/packages/node/Cargo.toml b/packages/node/Cargo.toml index 34160e42..9171a4ec 100644 --- a/packages/node/Cargo.toml +++ b/packages/node/Cargo.toml @@ -28,6 +28,7 @@ console_error_panic_hook = { version = "0.1.7", optional = true } [dev-dependencies] wasm-bindgen-test = "0.3.77" +rstest = "0.26.1" # Disable wasm-pack's bundled (legacy) wasm-opt; the bundled version does not # support bulk-memory ops emitted by modern rustc. The `build` script in diff --git a/packages/node/src/lib.rs b/packages/node/src/lib.rs index f21b5dd1..e9d569c1 100644 --- a/packages/node/src/lib.rs +++ b/packages/node/src/lib.rs @@ -56,19 +56,7 @@ pub fn translate_to_unicode_with_trace(text: &str) -> Result Result "korean", - braillify::TracePath::EnglishUeb => "english-ueb", - braillify::TracePath::MathExpression => "math", - } - .to_string(), + path: path_label(trace.path()).to_string(), + }) +} + +/// One event as a span, or `None` for an id the registry does not resolve. +fn rule_span( + rule: braillify::RuleId, + output: core::ops::Range, + cells: &[u8], +) -> Option { + let meta = rule.meta()?; + let kind = rule.kind()?; + let range = output.start as usize..output.end as usize; + Some(RuleSpan { + section: meta.section.to_string(), + name: meta.name.to_string(), + description: meta.description.to_string(), + kind: kind_label(kind).to_string(), + start: output.start, + end: output.end, + braille: to_braille(cells.get(range).unwrap_or_default()), }) } +fn path_label(path: braillify::TracePath) -> &'static str { + match path { + braillify::TracePath::KoreanRules => "korean", + braillify::TracePath::EnglishUeb => "english-ueb", + braillify::TracePath::MathExpression => "math", + } +} + fn kind_label(kind: braillify::RuleKind) -> &'static str { match kind { braillify::RuleKind::Korean => "korean", @@ -152,4 +163,58 @@ mod tests { // Exercises the no-op path on default (no `console_error_panic_hook` feature). utils::set_panic_hook(); } + + #[test] + fn trace_reports_the_rules_behind_the_braille() { + let result = translate_to_unicode_with_trace("안녕").expect("must succeed"); + + assert_eq!(result.path, "korean"); + assert_eq!(result.attributed, result.total); + assert!(!result.rules.is_empty()); + for span in &result.rules { + assert!(span.end > span.start, "a span must cover a cell"); + assert_eq!(span.braille.chars().count() as u32, span.end - span.start); + } + } + + #[test] + fn trace_propagates_error() { + assert!(translate_to_unicode_with_trace("😀").is_err()); + } + + /// An id outside the registry names no rule, so it yields no span. + #[test] + fn an_unresolvable_id_yields_no_span() { + assert!(rule_span(braillify::RuleId::UNATTRIBUTED, 0..1, &[0]).is_none()); + } + + /// A span reaching past the output keeps its cells empty rather than + /// panicking, so a stale range can never take the binding down. + #[test] + fn a_span_past_the_output_carries_no_cells() { + let (_, trace) = braillify::encode_with_trace("안녕").expect("must encode"); + let rule = trace.events().first().expect("안녕 records events").rule; + let span = rule_span(rule, 0..99, &[0]).expect("the emitter id resolves"); + + assert!(span.braille.is_empty()); + } + + #[rstest::rstest] + #[case::korean(braillify::RuleKind::Korean, "korean")] + #[case::token(braillify::RuleKind::Token, "token")] + #[case::math(braillify::RuleKind::Math, "math")] + #[case::jamo(braillify::RuleKind::Jamo, "jamo")] + #[case::english_ueb(braillify::RuleKind::EnglishUeb, "english-ueb")] + #[case::emitter(braillify::RuleKind::Emitter, "emitter")] + fn every_engine_has_a_label(#[case] kind: braillify::RuleKind, #[case] expected: &str) { + assert_eq!(kind_label(kind), expected); + } + + #[rstest::rstest] + #[case::korean(braillify::TracePath::KoreanRules, "korean")] + #[case::english_ueb(braillify::TracePath::EnglishUeb, "english-ueb")] + #[case::math(braillify::TracePath::MathExpression, "math")] + fn every_path_has_a_label(#[case] path: braillify::TracePath, #[case] expected: &str) { + assert_eq!(path_label(path), expected); + } } From 78e7fb7464e341bfe60d2e11511e93fb55c86249 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 16:28:20 +0900 Subject: [PATCH 004/132] Reach the last traced lines, and delete the two nothing reaches The remaining gap was the traced side of guards no test entered: a rule that matches and then skips, the token engine's rewrite shapes, the forced UEB encode, and the math route both when it lands and when it is thrown away. Each now has a test that goes through the public entry point where one exists. Two of them no input could reach. The encoder only dropped its origin table when a transform had already changed the token count, which the traced path never does, and the emitter built a fallback rule id inside a branch that the same reasoning made dead. Both now sit in code that every call runs, so what was unreachable is gone rather than merely excused. The encoder still emits exactly what it did: fixtures 5141 of 5141, corpus 455,975 of 467,121, marker bench 837 / 145 / 305 / 398. --- libs/braillify/src/encoder.rs | 4 +- libs/braillify/src/lib.rs | 86 ++++++++++++++ libs/braillify/src/rules/emit.rs | 48 +++++--- libs/braillify/src/rules/engine.rs | 109 ++++++++++++++++++ .../src/rules/english_ueb/contraction.rs | 14 +++ libs/braillify/src/rules/token_engine.rs | 77 ++++++++++++- libs/braillify/src/rules/trace.rs | 49 ++++++++ 7 files changed, 365 insertions(+), 22 deletions(-) diff --git a/libs/braillify/src/encoder.rs b/libs/braillify/src/encoder.rs index 09c136ed..273a2f61 100644 --- a/libs/braillify/src/encoder.rs +++ b/libs/braillify/src/encoder.rs @@ -292,9 +292,7 @@ impl Encoder { // `transform` injects formatting tokens without origin tracking, so the // side table no longer lines up with the stream and must be dropped. - if origins.as_ref().is_some_and(|o| o.len() != ir.tokens.len()) { - origins = None; - } + let origins = origins.filter(|o| o.len() == ir.tokens.len()); let output = rules::emit::emit(&mut ir, &mut self.rule_engine, trace, origins.as_ref()); rules::math::end_collection(); diff --git a/libs/braillify/src/lib.rs b/libs/braillify/src/lib.rs index e50d8f29..197a0ff4 100644 --- a/libs/braillify/src/lib.rs +++ b/libs/braillify/src/lib.rs @@ -1837,6 +1837,92 @@ mod trace_tests { ); } + /// 수학 제32·33항's 합동/기하 glyphs (`△`, `→`, `□`, `≅`) pull the whole string + /// onto the math route, which encodes *before* the token pipeline and so + /// carries its own sink instead of the emitter's origin table. Without that + /// sink the expression would encode with nothing recorded at all. + #[rstest::rstest] + #[case::congruence_triangle("△ABC")] + #[case::implication("p → q")] + #[case::relation("A≅B")] + fn a_whole_route_math_expression_names_its_math_rules(#[case] input: &str) { + let (cells, trace) = encode_with_trace(input).expect("input must encode"); + + assert_eq!(encode(input).expect("input must encode"), cells); + assert_eq!(trace.path(), TracePath::MathExpression); + assert_eq!(trace.attributed_cells(), cells.len() as u32); + assert!( + trace + .events() + .iter() + .any(|event| event.rule.kind() == Some(RuleKind::Math)), + "the math engine must name itself: {:?}", + trace.events() + ); + } + + /// The whole-route math encoder runs speculatively: it emits cells for the + /// tokens it consumed and only then discovers a token it cannot encode, at + /// which point the Korean pipeline re-encodes the whole input. The discarded + /// cells never ship, so crediting their rules would name rules that did not + /// produce the output — and would leave two rules claiming the same cell. + #[rstest::rstest] + #[case::trailing_at_sign("△AB@")] + #[case::percent_between_operands("△A%B")] + #[case::bare_at_sign("A□@B")] + fn a_failed_math_route_credits_no_rule_for_the_cells_it_threw_away(#[case] input: &str) { + let (cells, trace) = encode_with_trace(input).expect("input must encode"); + + assert_ne!( + trace.path(), + TracePath::MathExpression, + "the math route must have failed for this case to mean anything" + ); + assert!( + trace + .events() + .iter() + .all(|event| event.rule.kind() != Some(RuleKind::Math)), + "rolled-back math rules must not survive: {:?}", + trace.events() + ); + + let mut claims = vec![0u32; cells.len()]; + for event in trace.events() { + for cell in event.output.clone() { + claims[cell as usize] += 1; + } + } + assert!( + claims.iter().all(|count| *count == 1), + "cells claimed {claims:?} times in {input:?}: {:?}", + trace.events() + ); + } + + /// `EncodingMode::English` forces the UEB engine even where content routing + /// would not pick it — a letterless `4:30` reads as a Korean-context number + /// otherwise. The forced entry point has to collect the same spans as the + /// content-routed one, or a declared-English testcase would trace as though + /// no rule had run. + #[rstest::rstest] + #[case::letterless_time("4:30")] + #[case::prose("the child")] + fn forced_english_mode_still_names_its_ueb_rules(#[case] input: &str) { + let options = EncodeOptions { + default_mode: Some(EncodingMode::English), + }; + let (cells, trace) = + encode_with_options_and_trace(input, &options).expect("input must encode"); + + assert_eq!( + encode_with_options(input, &options).expect("input must encode"), + cells + ); + assert_eq!(trace.path(), TracePath::EnglishUeb); + assert_eq!(trace.attributed_cells(), cells.len() as u32); + } + #[test] fn contributing_rules_lists_each_rule_once() { let (_, trace) = encode_with_trace("가나다 라마").expect("input must encode"); diff --git a/libs/braillify/src/rules/emit.rs b/libs/braillify/src/rules/emit.rs index ec4229f9..6a13d94a 100644 --- a/libs/braillify/src/rules/emit.rs +++ b/libs/braillify/src/rules/emit.rs @@ -477,9 +477,14 @@ pub fn emit( if !is_math_operator_space_suppression(&ir.tokens, idx) { let start = result.len(); result.push(0); - record_token_span(&mut trace, origins, idx, &result, start, || { - RuleId::emitter(EmitterRule::WordSpace) - }); + record_token_span( + &mut trace, + origins, + idx, + &result, + start, + RuleId::emitter(EmitterRule::WordSpace), + ); } } Token::Mode(event) => { @@ -511,9 +516,14 @@ pub fn emit( let start = result.len(); enter_roman_before_ueb_prefix(&ir.tokens, idx, event, &mut ir.state, &mut result); emit_mode_event(event, &mut ir.state, &mut result); - record_token_span(&mut trace, origins, idx, &result, start, || { - RuleId::emitter(EmitterRule::UndeclaredTokenOutput) - }); + record_token_span( + &mut trace, + origins, + idx, + &result, + start, + RuleId::emitter(EmitterRule::UndeclaredTokenOutput), + ); } Token::Fraction(frac) => { let start = result.len(); @@ -530,9 +540,14 @@ pub fn emit( )?); } ir.state.is_number = true; - record_token_span(&mut trace, origins, idx, &result, start, || { - RuleId::emitter(EmitterRule::UndeclaredTokenOutput) - }); + record_token_span( + &mut trace, + origins, + idx, + &result, + start, + RuleId::emitter(EmitterRule::UndeclaredTokenOutput), + ); } Token::PreEncoded(bytes) => { // 제39항 한글 wrap 점형은 영어 모드를 자동으로 휴면(⠸⠷)·재개(⠸⠾)시킨다. @@ -545,9 +560,14 @@ pub fn emit( } let start = result.len(); result.extend(bytes); - record_token_span(&mut trace, origins, idx, &result, start, || { - RuleId::emitter(EmitterRule::UndeclaredTokenOutput) - }); + record_token_span( + &mut trace, + origins, + idx, + &result, + start, + RuleId::emitter(EmitterRule::UndeclaredTokenOutput), + ); } } } @@ -599,7 +619,7 @@ fn record_token_span( idx: usize, result: &[u8], start: usize, - fallback: impl FnOnce() -> RuleId, + fallback: RuleId, ) { let Some(sink) = trace.as_mut() else { return; @@ -615,7 +635,7 @@ fn record_token_span( } return; } - let rule = origins.and_then(|o| o.get(idx)).unwrap_or_else(fallback); + let rule = origins.and_then(|o| o.get(idx)).unwrap_or(fallback); sink.record_span(rule, idx, start..end); } diff --git a/libs/braillify/src/rules/engine.rs b/libs/braillify/src/rules/engine.rs index 765cec19..0c9d0ecb 100644 --- a/libs/braillify/src/rules/engine.rs +++ b/libs/braillify/src/rules/engine.rs @@ -520,6 +520,115 @@ mod tests { assert_eq!(outcome, RuleResult::Skip); } + /// `TraceSpan::close` records by what a rule PRODUCED, not by what it + /// returned. A few rules write a mode indicator and still return `Skip` so + /// the next rule encodes the character — 제29항 로마자표 is the usual one — + /// and those cells are in the output, so something has to account for them. + /// A rule that returned `Skip` without writing anything explains nothing + /// and must stay out of the trace. + #[test] + fn a_skipping_rule_is_recorded_only_when_it_wrote_cells() { + use crate::char_struct::CharType; + use crate::rules::trace::{Trace, TraceSink}; + + static META_INDICATOR: RuleMeta = RuleMeta { + section: "indicator-skip", + subsection: None, + name: "indicator_then_skip", + standard_ref: "", + description: "writes a mode indicator, then defers to the next rule", + }; + static META_SILENT: RuleMeta = RuleMeta { + section: "silent-skip", + subsection: None, + name: "silent_skip", + standard_ref: "", + description: "matches but declines without writing anything", + }; + + struct IndicatorThenSkip; + impl BrailleRule for IndicatorThenSkip { + fn meta(&self) -> &'static RuleMeta { + &META_INDICATOR + } + fn phase(&self) -> Phase { + Phase::CoreEncoding + } + fn matches(&self, _: &RuleContext) -> bool { + true + } + fn apply(&self, ctx: &mut RuleContext) -> Result { + ctx.emit(48); + Ok(RuleResult::Skip) + } + } + + struct SilentSkip; + impl BrailleRule for SilentSkip { + fn meta(&self) -> &'static RuleMeta { + &META_SILENT + } + fn phase(&self) -> Phase { + Phase::CoreEncoding + } + fn matches(&self, _: &RuleContext) -> bool { + true + } + fn apply(&self, _: &mut RuleContext) -> Result { + Ok(RuleResult::Skip) + } + } + + let mut engine = RuleEngine::new(); + engine.register(Box::new(IndicatorThenSkip)); + engine.register(Box::new(SilentSkip)); + + let word_chars = vec!['x']; + let char_type = CharType::English('x'); + let empty: [&str; 0] = []; + let mut skip = 0usize; + let mut state = EncoderState::new(false); + let mut result = Vec::new(); + let mut trace = Trace::default(); + { + let mut ctx = RuleContext { + word_chars: &word_chars, + index: 0, + char_type: &char_type, + prev_word: "", + remaining_words: &empty, + has_korean_char: false, + is_all_uppercase: false, + ascii_starts_at_beginning: false, + roman_section_continues_from_previous_word: false, + skip_count: &mut skip, + state: &mut state, + result: &mut result, + }; + + let outcome = engine + .apply_phase( + Phase::CoreEncoding, + &mut ctx, + Some(TraceSink::new(&mut trace)), + ) + .expect("neither rule fails"); + + assert_eq!(outcome, RuleResult::Skip); + } + + assert_eq!(result, vec![48]); + assert_eq!( + trace.events().len(), + 1, + "only the rule that wrote a cell is recorded: {:?}", + trace.events() + ); + assert_eq!(trace.events()[0].rule, RuleId::korean(0)); + assert_eq!(trace.events()[0].outcome, RuleOutcome::Continued); + assert_eq!(trace.events()[0].output, 0..1); + } + /// engine.rs line 124 - `apply_phase` skip arm for disabled rules. #[test] fn engine_apply_phase_skips_disabled_rules() { diff --git a/libs/braillify/src/rules/english_ueb/contraction.rs b/libs/braillify/src/rules/english_ueb/contraction.rs index 146b9170..94fb0f59 100644 --- a/libs/braillify/src/rules/english_ueb/contraction.rs +++ b/libs/braillify/src/rules/english_ueb/contraction.rs @@ -256,6 +256,20 @@ mod tests { assert_eq!(cells, vec![decode_unicode('⠃')]); } + /// §10.4 strong groupsigns and §10.6 lower groupsigns are matched through + /// the §10.11 bridge rule and the §10.6.4/§10.6.8 gated rules rather than + /// registered on their own, so neither has had its section checked against + /// the standard. Reporting the undeclared placeholder keeps their cells out + /// of a section nobody verified instead of crediting a plausible-looking + /// one, which a trace consumer would have no way to distrust. + #[rstest::rstest] + #[case::strong_groupsign(&crate::rules::english_ueb::rule_10_4::StrongGroupsignRule)] + #[case::lower_groupsign(&crate::rules::english_ueb::rule_10_6::LowerGroupsignRule)] + fn an_undeclared_rule_reports_the_placeholder_section(#[case] rule: &dyn ContractionRule) { + assert_eq!(rule.meta().name, "undeclared_ueb_rule"); + assert_eq!(rule.meta().section, "?"); + } + #[test] fn match_longest_accepts_runtime_word_slice() { static MAP: phf::Map<&'static str, u8> = phf::phf_map! { diff --git a/libs/braillify/src/rules/token_engine.rs b/libs/braillify/src/rules/token_engine.rs index a04ba0b2..78209605 100644 --- a/libs/braillify/src/rules/token_engine.rs +++ b/libs/braillify/src/rules/token_engine.rs @@ -131,11 +131,8 @@ impl TokenRuleEngine { continue; } } - debug_assert_eq!( - origins.as_deref().map_or(tokens.len(), TokenOrigins::len), - tokens.len(), - "origin tracking must stay in lockstep with the token stream" - ); + let tracked = origins.as_deref().map_or(tokens.len(), TokenOrigins::len); + debug_assert_eq!(tracked, tokens.len(), "origin table lost lockstep"); break; } i += 1; @@ -465,6 +462,76 @@ mod tests { assert!(matches!(&tokens[0], Token::Word(w) if w.text == "a")); } + #[derive(Clone, Copy, Debug)] + enum Rewrite { + InsertBefore, + ReplaceMany, + ReplaceRange, + Remove, + } + + struct RewriteB(Rewrite); + impl TokenRule for RewriteB { + fn phase(&self) -> TokenPhase { + TokenPhase::WordShortcut + } + fn apply<'a>( + &self, + tokens: &[Token<'a>], + index: usize, + _state: &mut EncoderState, + ) -> Result, String> { + let Some(Token::Word(word)) = tokens.get(index) else { + return Ok(TokenAction::Noop); + }; + if word.text != "b" { + return Ok(TokenAction::Noop); + } + Ok(match self.0 { + Rewrite::InsertBefore => { + TokenAction::InsertBefore(vec![Token::PreEncoded(vec![1])]) + } + Rewrite::ReplaceMany => TokenAction::ReplaceMany(vec![ + Token::PreEncoded(vec![1]), + Token::PreEncoded(vec![2]), + ]), + Rewrite::ReplaceRange => { + TokenAction::ReplaceRange(1, vec![Token::PreEncoded(vec![3])]) + } + Rewrite::Remove => TokenAction::Remove, + }) + } + } + + /// The emitter names a token's producer by looking its position up in the + /// origin table, so every rewrite shape must leave that table the same + /// length as the stream and must claim exactly the slots it created. A + /// shape that resized one but not the other would silently shift every + /// later token's attribution onto the wrong rule. + #[rstest::rstest] + #[case::insert_before(Rewrite::InsertBefore)] + #[case::replace_many(Rewrite::ReplaceMany)] + #[case::replace_range(Rewrite::ReplaceRange)] + #[case::remove(Rewrite::Remove)] + fn origin_tracking_stays_in_lockstep_with_every_rewrite_shape(#[case] rewrite: Rewrite) { + let mut engine = TokenRuleEngine::new(); + engine.register(Box::new(RewriteB(rewrite))); + + let mut tokens = vec![word_token("a"), word_token("b"), word_token("c")]; + let mut state = EncoderState::new(false); + let mut origins = TokenOrigins::seeded(tokens.len()); + + engine + .apply_all_tracked(&mut tokens, &mut state, Some(&mut origins)) + .expect("the rewrite rule never fails"); + + assert_eq!(origins.len(), tokens.len(), "{rewrite:?} resized one side"); + for (index, token) in tokens.iter().enumerate() { + let expected = matches!(token, Token::PreEncoded(_)).then(|| RuleId::token(0)); + assert_eq!(origins.get(index), expected, "{rewrite:?} slot {index}"); + } + } + /// token_engine.rs lines 95-96 - `impl Default::default()` body. #[test] fn token_rule_engine_default_constructs_empty() { diff --git a/libs/braillify/src/rules/trace.rs b/libs/braillify/src/rules/trace.rs index fd91435a..09cebc7e 100644 --- a/libs/braillify/src/rules/trace.rs +++ b/libs/braillify/src/rules/trace.rs @@ -738,6 +738,55 @@ mod tests { assert_eq!(trace.events()[0].rule, RuleId(1)); } + /// A sink is rebound to each token as the emitter walks the stream, so an + /// event names the token it came from. The math engine reads that index + /// back out to place its own spans, which is why the binding is readable + /// rather than write-only. + #[test] + fn a_sink_reports_the_token_it_is_bound_to() { + let mut trace = Trace::default(); + let mut sink = TraceSink::new(&mut trace); + assert_eq!(sink.token_index(), 0); + + let mut at_third = sink.at_token(3); + + assert_eq!(at_third.token_index(), 3); + assert_eq!(at_third.reborrow().token_index(), 3); + } + + /// `output_len` is the denominator [`Trace::attributed_cells`] is read + /// against, so it counts the cells the encode produced rather than the + /// cells the events happen to cover. + #[test] + fn output_len_counts_the_encoded_cells_not_the_recorded_ones() { + let mut trace = Trace::default(); + assert_eq!(trace.output_len(), 0); + + trace.set_output_len(5); + trace.push(event(1, 0..2)); + + assert_eq!(trace.output_len(), 5); + assert_eq!(trace.attributed_cells(), 2); + assert_eq!(trace.unattributed_cells(), 3); + } + + /// A token rule may delete a token outright. Dropping the matching origin + /// slot is what keeps the side table indexable by token position; an index + /// past the end has no slot to drop. + #[test] + fn removing_a_token_drops_exactly_its_origin_slot() { + let mut origins = TokenOrigins::seeded(3); + origins.set(0, RuleId::token(1)); + origins.set(2, RuleId::token(2)); + + origins.remove(1); + origins.remove(9); + + assert_eq!(origins.len(), 2); + assert_eq!(origins.get(0), Some(RuleId::token(1))); + assert_eq!(origins.get(1), Some(RuleId::token(2))); + } + #[test] fn token_origins_survive_a_splice_that_changes_length() { let mut origins = TokenOrigins::seeded(3); From ca8e5541c9c43473ecd26d97fa7a5fb1f9fff3ee Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 16:56:32 +0900 Subject: [PATCH 005/132] Prove the ampersand needs a Roman word on both sides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spaced ampersand of 제29항 asks whether a Roman word stands on each side, and the two answers it can reach without finding one had no test: already encoded output, which proves nothing about what it holds, and the end of the token stream, which proves nothing at all. --- libs/braillify/src/rules/emit.rs | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/libs/braillify/src/rules/emit.rs b/libs/braillify/src/rules/emit.rs index 6a13d94a..0e1fa3cc 100644 --- a/libs/braillify/src/rules/emit.rs +++ b/libs/braillify/src/rules/emit.rs @@ -2306,6 +2306,42 @@ mod spaced_colon_coverage { }) } + /// 제29항: a spaced `&` joins two Roman words, so it needs a Roman word on + /// each side. Already-encoded output and the end of the stream both prove + /// nothing, so neither side may be read as Roman. + #[rstest::rstest] + #[case::nothing_follows(vec![word("A"), Token::Space(SpaceKind::Regular), word("&")], 2)] + #[case::encoded_output_follows( + vec![ + word("A"), + Token::Space(SpaceKind::Regular), + word("&"), + Token::Space(SpaceKind::Regular), + Token::PreEncoded(vec![1]), + ], + 2 + )] + #[case::nothing_precedes(vec![word("&"), Token::Space(SpaceKind::Regular), word("B")], 0)] + #[case::encoded_output_precedes( + vec![ + Token::PreEncoded(vec![1]), + Token::Space(SpaceKind::Regular), + word("&"), + Token::Space(SpaceKind::Regular), + word("B"), + ], + 2 + )] + fn an_ampersand_without_a_roman_word_on_both_sides_joins_nothing( + #[case] tokens: Vec>, + #[case] ampersand_index: usize, + ) { + assert!(!spaced_ampersand_connects_roman_words( + &tokens, + ampersand_index + )); + } + /// 제29항·제32항·제35항: a standalone colon joins two Roman items only when /// the item after it is proved Roman. #[test] From 27fa913bcf8d8621c2bf28fbd69b4b30cb800796 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 17:49:39 +0900 Subject: [PATCH 006/132] Let a label answered in Roman or figures take its blank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Article 51's body parts a 표제 from its 내용 with a 쌍점 written against the label and followed by one blank, and the rule already did that — but only when Korean stood on both sides of the colon. What the 내용 happens to be written in was never what the article turned on, so `모델명:PN50` and `일시:2006년` were being run straight on. The test stays on the left of the colon. Nothing Korean in front of it means the mark was never a 쌍점 at all but a sign inside a Roman identifier, which is how `NVH:Noise` and `A:IR` keep running on. [다만 2]'s exceptions are untouched for the same reason: `오전 10:20` and `요한 3:16` have a figure in front, and `청군:백군` is still caught by the 대비 쌍 test above. 25 more corpus sentences read correctly, 456,000 of 467,121. The marker bench reads 838 / 145 / 305 / 398 against 837 / 145 / 305 / 398, one more error over 66 more sentences that now line up word-for-word and enter the comparison at all (8,572 to 8,638). On the shared sentences the markers are unchanged. --- .../rules/token_rules/middle_dot_spacing.rs | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/libs/braillify/src/rules/token_rules/middle_dot_spacing.rs b/libs/braillify/src/rules/token_rules/middle_dot_spacing.rs index 7e2ce645..45789b03 100644 --- a/libs/braillify/src/rules/token_rules/middle_dot_spacing.rs +++ b/libs/braillify/src/rules/token_rules/middle_dot_spacing.rs @@ -199,11 +199,15 @@ fn korean_semicolon_split_index(chars: &[char]) -> Option { /// 대비 쌍이다(나머지 예 `오전 10:20`, `요한 3:16` 은 숫자 쌍이라 이 함수 밖이다). /// 따라서 한글 사이의 쌍점은 그 어절이 대비 쌍 꼴일 때만 붙이고, 괄호·따옴표 등이 /// 섞여 표제와 내용을 가르는 꼴이면 본문에 따라 뒤에 한 칸을 둔다. +/// +/// 표제를 한글이 이끄는 한 내용이 무엇으로 적혔는지는 본문을 바꾸지 않는다 +/// (`모델명:PN50`, `일시:2006년`). 쌍점 앞이 한글이 아니면 애초에 쌍점이 아니라 +/// 로마자 식별자 안의 기호이므로(`NVH:Noise`) 이 함수가 보지 않는다. fn korean_label_colon_split_index(chars: &[char]) -> Option { let position = chars.windows(3).position(|window| { crate::utils::is_korean_char(window[0]) && window[1] == ':' - && crate::utils::is_korean_char(window[2]) + && !is_closing_after_colon(window[2]) })?; let is_contrast_pair = chars .iter() @@ -785,3 +789,41 @@ mod hugging_punctuation { assert!(actual.contains("⠀⠸⠌⠀"), "slash must stay spaced: {actual}"); } } + +#[cfg(test)] +mod label_colon_before_non_korean { + /// 제51항 본문 — a 쌍점 parting a 표제 from its 내용 is attached on its left and + /// followed by one blank. What the 내용 is written in does not change that, so + /// a label answered in Roman letters or figures takes the blank exactly as a + /// Korean one does. + #[rstest::rstest] + #[case::roman_content("프로젝트명:RP 가나", "⠐⠂⠀⠴")] + #[case::roman_and_digits("모델명:PN50 가나", "⠐⠂⠀⠴")] + #[case::digit_content("일시:2006년 가나", "⠐⠂⠀⠼")] + fn a_label_answered_in_roman_or_figures_takes_the_blank( + #[case] input: &str, + #[case] expected: &str, + ) { + let actual = crate::encode_to_unicode(input).expect("label must encode"); + assert!( + actual.contains(expected), + "colon must be followed by a blank: {actual}" + ); + } + + /// 제51항 [다만 2] keeps 시:분 and 장:절 attached, and a 대비 쌍 such as + /// `청군:백군` is the same shape. A colon inside a Roman identifier + /// (`NVH:Noise`) never was a 쌍점 — nothing Korean stands before it. + #[rstest::rstest] + #[case::contrast_pair("청군:백군", "⠐⠂⠘⠗")] + #[case::hour_and_minute("오전 10:20", "⠼⠁⠚⠐⠂⠼⠃⠚")] + #[case::chapter_and_verse("요한 3:16", "⠼⠉⠐⠂⠼⠁⠋")] + #[case::roman_identifier("가나 NVH:Noise 다라", "⠓⠒⠠⠝")] + fn the_excepted_colons_stay_attached(#[case] input: &str, #[case] expected: &str) { + let actual = crate::encode_to_unicode(input).expect("colon must encode"); + assert!( + actual.contains(expected), + "colon must stay attached: {actual}" + ); + } +} From 94d640390877fbd919e4f93920a616ea91883fc6 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 18:22:37 +0900 Subject: [PATCH 007/132] Read past the Korean naming the figures a middle dot joins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 가운뎃점 between figures names an event or an issue — 제주4·3, 광주5·18, 통권 제54·55·56호 — and 제5항 writes it ⠐⠆. Print often sets the Korean that names them against the figures, and the detector judged the whole word, so the Korean prefix made it fail the numeric test and the word fell through to the math route, where the dot became a product and 제11항 wrapped it in two blanks. The prefix is not noise in that judgement; it is the evidence. Reading past it and judging the figures alone leaves 제주4·3운동 and 제54·55·56호 exactly as they were, since neither reached the math route to begin with. 25 more corpus sentences read correctly, 456,025 of 467,121, with the marker bench unmoved at 838 / 145 / 305 / 398. --- .../token_rules/math_expression/helpers.rs | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/libs/braillify/src/rules/token_rules/math_expression/helpers.rs b/libs/braillify/src/rules/token_rules/math_expression/helpers.rs index 1ad7dd94..8d405e13 100644 --- a/libs/braillify/src/rules/token_rules/math_expression/helpers.rs +++ b/libs/braillify/src/rules/token_rules/math_expression/helpers.rs @@ -58,14 +58,22 @@ pub(super) fn is_combining_math_mark(c: char) -> bool { } pub(super) fn is_middle_dot_numeric_word(chars: &[char]) -> bool { - let middle_dot_count = chars + // 제주4·3, 광주5·18 — the Korean naming the figures is often set against + // them in print, and that prefix is precisely what says the dot joins two + // parts of a name rather than multiplying. Read past it before judging the + // figures; a word that is Korean throughout has no figures to judge. + let figures = chars + .iter() + .position(|c| !is_korean_char(*c)) + .map_or(&chars[..0], |start| &chars[start..]); + let middle_dot_count = figures .iter() .filter(|c| matches!(**c, '\u{00B7}' | '\u{22C5}')) .count(); if middle_dot_count == 0 { return false; } - chars.iter().all(|c| { + figures.iter().all(|c| { c.is_ascii_digit() || matches!( *c, @@ -1081,3 +1089,30 @@ mod korean_prefix_sign_coverage { ); } } + +#[cfg(test)] +mod korean_prefixed_middle_dot { + /// A 가운뎃점 between figures names an event or an issue (`제주4·3`, + /// `10·26`), and 제5항 writes it ⠐⠆. The Korean naming the figures may be + /// attached to them in print, and that prefix is what says the dot is not a + /// product — so it must not push the word onto the math route, where 제11항 + /// would also wrap it in two blank cells. + #[rstest::rstest] + #[case::korean_prefix_then_space("제주4·3 70주년")] + #[case::korean_prefix_only("가나4·3 다라")] + #[case::detached("제주 4·3 70주년")] + #[case::korean_suffix("제주4·3운동")] + #[case::issue_numbers("통권 제54·55·56호")] + fn figures_named_by_korean_keep_the_middle_dot(#[case] input: &str) { + let encoded = crate::encode_to_unicode(input).expect("input must encode"); + + assert!( + encoded.contains('\u{2806}'), + "제5항 가운뎃점 ⠐⠆ must survive: {encoded}" + ); + assert!( + !encoded.contains("\u{2800}\u{2800}"), + "제11항 math boundary must not appear: {encoded}" + ); + } +} From 495a7b1b7551ed8c88c956f5550ba7279b9d86f5 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 19:26:06 +0900 Subject: [PATCH 008/132] Name the article behind every Korean and token rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tracer answers which rule wrote each braille cell, but sixteen rules answered with a placeholder rather than an article: two carried a word where a number belongs, and the rest inherited the trait default that exists precisely to say nobody has checked yet. rule_map.json at the repo root settles them. It holds all 448 articles with their text, so each rule was matched against the one whose wording describes what it does — 제46항 for the operator spacing that gives rule_math its name, 제53항 for the ellipsis, 제47항 for both fraction detectors, 제60항 for the asterisk, 제54항 for the quote attachment and for the tortoise-shell gloss that turns out to be the same bracket rule, 제35항 for the digital notation, 제19항 for the head of the 옛 글자 articles the middle-Korean detector switches into, 제49항 for the two dash and bracket spacing rules added earlier, and RUEB 8.4 for the capitals run, which no Korean article governs. Two take the honest marker the emitter's inter-word blank already uses: the space rule encodes the blank cell itself, and the LaTeX merge only joins a formula split across blanks and writes nothing. Over 5,160 traced sentences the placeholders fall from 4,331 to 2,378, and what remains is the math symbol dispatch, which needs an article per branch rather than one for the whole chain. Behaviour is untouched: fixtures 5141 of 5141, corpus 456,025 of 467,121, marker bench 838 / 145 / 305 / 398. --- libs/braillify/src/rules/korean/rule_math.rs | 13 +++-- libs/braillify/src/rules/korean/rule_space.rs | 16 +++++-- .../src/rules/token_rules/digital_notation.rs | 7 +-- .../token_rules/historical_gloss_spacing.rs | 7 +-- .../src/rules/token_rules/inline_fraction.rs | 7 +-- .../src/rules/token_rules/latex_fraction.rs | 7 +-- .../token_rules/latex_math/merge_rule.rs | 7 +-- .../rules/token_rules/middle_dot_spacing.rs | 48 +++++++++++++++++++ .../token_rules/middle_korean_detector.rs | 7 +-- .../src/rules/token_rules/normalize.rs | 7 +-- .../src/rules/token_rules/quote_attachment.rs | 7 +-- .../src/rules/token_rules/spacing.rs | 7 +-- .../rules/token_rules/uppercase_passage.rs | 7 +-- libs/braillify/src/rules/trace.rs | 23 +++++++++ 14 files changed, 134 insertions(+), 36 deletions(-) diff --git a/libs/braillify/src/rules/korean/rule_math.rs b/libs/braillify/src/rules/korean/rule_math.rs index 450aec84..479bccdc 100644 --- a/libs/braillify/src/rules/korean/rule_math.rs +++ b/libs/braillify/src/rules/korean/rule_math.rs @@ -1,4 +1,4 @@ -//! Math symbol encoding with Korean spacing rules. +//! 제46항: 연산 기호와 비교 기호가 한글 사이에 나올 때에는 기호의 앞뒤를 한 칸씩 띄어 쓴다. //! //! Math symbols (+, −, ×, ÷, etc.) need spacing around them when //! adjacent to Korean text, unless the Korean is a grammatical particle (josa). @@ -11,10 +11,10 @@ use crate::rules::traits::{BrailleRule, Phase, RuleResult}; use crate::utils; pub static META: RuleMeta = RuleMeta { - section: "math", + section: "46", subsection: None, name: "math_symbol_encoding", - standard_ref: "2024 Korean Braille Standard (math symbols)", + standard_ref: "2024 Korean Braille Standard, 제46항", description: "Math symbols with Korean spacing rules", }; @@ -541,6 +541,13 @@ mod tests { "input={input}" ); } + + /// 제46항 "연산 기호와 비교 기호가 한글 사이에 나올 때에는 기호의 앞뒤를 한 칸씩 띄어 쓴다" + /// 이 규칙의 메타데이터는 제46항을 명시해야 한다. + #[test] + fn meta_section_is_article_46() { + assert_eq!(META.section, "46", "META.section must be article 46"); + } } #[cfg(test)] diff --git a/libs/braillify/src/rules/korean/rule_space.rs b/libs/braillify/src/rules/korean/rule_space.rs index 60f3bf01..ae3dcdf4 100644 --- a/libs/braillify/src/rules/korean/rule_space.rs +++ b/libs/braillify/src/rules/korean/rule_space.rs @@ -1,4 +1,4 @@ -//! Space character encoding. +//! 빈칸 자체는 어떤 규정 항목에도 속하지 않음. //! //! Spaces → 0, newlines → 255. @@ -8,10 +8,10 @@ use crate::rules::context::RuleContext; use crate::rules::traits::{BrailleRule, Phase, RuleResult}; pub static META: RuleMeta = RuleMeta { - section: "space", + section: "-", subsection: None, name: "space_encoding", - standard_ref: "N/A", + standard_ref: "빈칸 자체", description: "Encode space (0) and newline (255)", }; @@ -57,4 +57,14 @@ mod tests { let ctx = owned.ctx_at(0); let _ = RuleSpace.matches(&ctx); } + + /// 빈칸 자체는 어떤 규정 항목에도 속하지 않으므로, 정직한 표시로 "-"를 사용한다. + /// trace.rs의 WORD_SPACE_META 선례를 따른다. + #[test] + fn meta_section_is_dash_for_non_article() { + assert_eq!( + META.section, "-", + "META.section must be dash for non-article blank cell" + ); + } } diff --git a/libs/braillify/src/rules/token_rules/digital_notation.rs b/libs/braillify/src/rules/token_rules/digital_notation.rs index f2646dff..8d875194 100644 --- a/libs/braillify/src/rules/token_rules/digital_notation.rs +++ b/libs/braillify/src/rules/token_rules/digital_notation.rs @@ -17,13 +17,14 @@ use std::sync::LazyLock; static DIGITAL_INITIAL_PRON_RULE: LazyLock = LazyLock::new(|| InitialContractionPronunciationRule::new(Box::new(CmuDictProvider::new()))); +/// 제35항이 로마자와 숫자가 이어질 때 로마자 종료표를 쓰지 않도록 하므로 디지털 표기를 처리한다. pub struct DigitalNotationRule; static META: crate::rules::RuleMeta = crate::rules::RuleMeta { - section: "?", + section: "35", subsection: None, - name: "undeclared_digital_notation", - standard_ref: "", + name: "digital_notation", + standard_ref: "2024 Korean Braille Standard, 제35항 로마자와 숫자", description: "숫자·기호가 섞인 디지털 표기 처리", }; diff --git a/libs/braillify/src/rules/token_rules/historical_gloss_spacing.rs b/libs/braillify/src/rules/token_rules/historical_gloss_spacing.rs index e16c5550..8ab6e8c2 100644 --- a/libs/braillify/src/rules/token_rules/historical_gloss_spacing.rs +++ b/libs/braillify/src/rules/token_rules/historical_gloss_spacing.rs @@ -1,13 +1,14 @@ use crate::rules::token::Token; use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; +/// 제54항이 묶음표 안쪽을 붙여 쓰도록 하므로 〔와 〕 안쪽 공백을 제거한다. pub struct HistoricalGlossSpacingRule; static META: crate::rules::RuleMeta = crate::rules::RuleMeta { - section: "?", + section: "54", subsection: None, - name: "undeclared_historical_gloss_spacing", - standard_ref: "", + name: "historical_gloss_spacing", + standard_ref: "2024 Korean Braille Standard, 제54항 묶음표 붙여 쓰기", description: "한자 음독 주석 주변 띄어쓰기 조정", }; diff --git a/libs/braillify/src/rules/token_rules/inline_fraction.rs b/libs/braillify/src/rules/token_rules/inline_fraction.rs index d69716fa..74303a73 100644 --- a/libs/braillify/src/rules/token_rules/inline_fraction.rs +++ b/libs/braillify/src/rules/token_rules/inline_fraction.rs @@ -8,13 +8,14 @@ use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; static FRACTION_REGEX: Lazy = Lazy::new(|| Regex::new(r"^(\d+)\/(\d+)").expect("Failed to compile FRACTION_REGEX")); +/// 제47항의 분모·분수표·분자 순서를 적용하기 위해 본문의 분수를 토큰으로 만든다. pub struct InlineFractionRule; static META: crate::rules::RuleMeta = crate::rules::RuleMeta { - section: "?", + section: "47", subsection: None, - name: "undeclared_inline_fraction", - standard_ref: "", + name: "inline_fraction", + standard_ref: "2024 Korean Braille Standard, 제47항 분수", description: "본문 속 N/N 표기를 분수 토큰으로 변환", }; diff --git a/libs/braillify/src/rules/token_rules/latex_fraction.rs b/libs/braillify/src/rules/token_rules/latex_fraction.rs index f4925d8c..da0bf119 100644 --- a/libs/braillify/src/rules/token_rules/latex_fraction.rs +++ b/libs/braillify/src/rules/token_rules/latex_fraction.rs @@ -2,13 +2,14 @@ use crate::fraction; use crate::rules::token::{FractionToken, Token}; use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; +/// 제47항의 분모·분수표·분자 순서를 적용하기 위해 LaTeX 분수를 토큰으로 만든다. pub struct LatexFractionRule; static META: crate::rules::RuleMeta = crate::rules::RuleMeta { - section: "?", + section: "47", subsection: None, - name: "undeclared_latex_fraction", - standard_ref: "", + name: "latex_fraction", + standard_ref: "2024 Korean Braille Standard, 제47항 분수", description: "LaTeX \\frac{}{} 표기를 분수 토큰으로 변환", }; diff --git a/libs/braillify/src/rules/token_rules/latex_math/merge_rule.rs b/libs/braillify/src/rules/token_rules/latex_math/merge_rule.rs index b9e63670..be1fd5ac 100644 --- a/libs/braillify/src/rules/token_rules/latex_math/merge_rule.rs +++ b/libs/braillify/src/rules/token_rules/latex_math/merge_rule.rs @@ -7,13 +7,14 @@ use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; use super::encode_latex_math_bytes_with_context; use super::math_context_from_state; +/// 조문 없는 전처리(`-`)이므로 공백으로 나뉜 LaTeX 수식 구간을 하나로 합친다. pub struct LatexMergeRule; static META: crate::rules::RuleMeta = crate::rules::RuleMeta { - section: "?", + section: "-", subsection: None, - name: "undeclared_latex_merge", - standard_ref: "", + name: "latex_merge", + standard_ref: "전처리, 조문 없음", description: "공백으로 끊긴 $...$ 수식 구간을 하나로 합침", }; diff --git a/libs/braillify/src/rules/token_rules/middle_dot_spacing.rs b/libs/braillify/src/rules/token_rules/middle_dot_spacing.rs index 45789b03..eda6b905 100644 --- a/libs/braillify/src/rules/token_rules/middle_dot_spacing.rs +++ b/libs/braillify/src/rules/token_rules/middle_dot_spacing.rs @@ -354,7 +354,19 @@ impl TokenRule for KoreanHyphenSpacingRule { /// 대로 붙인다. pub struct LeadingDashSpacingRule; +static META_LEADING_DASH_SPACING: RuleMeta = RuleMeta { + section: "49", + subsection: None, + name: "leading_dash_spacing", + standard_ref: "2024 Korean Braille Standard, 제49항", + description: "Add a blank after a dash that opens a line item", +}; + impl TokenRule for LeadingDashSpacingRule { + fn meta(&self) -> &'static RuleMeta { + &META_LEADING_DASH_SPACING + } + fn phase(&self) -> TokenPhase { TokenPhase::PostWord } @@ -410,7 +422,19 @@ fn seam_hugs(before: char, after: char) -> bool { /// 손대지 않는다. 빗금은 제33항 예시가 앞뒤를 띄우므로 여기에 넣지 않는다. pub struct HuggingPunctuationSpacingRule; +static META_HUGGING_PUNCTUATION_SPACING: RuleMeta = RuleMeta { + section: "49", + subsection: None, + name: "hugging_punctuation_spacing", + standard_ref: "2024 Korean Braille Standard, 제49항", + description: "Close editorial gaps inside brackets according to Korean orthography", +}; + impl TokenRule for HuggingPunctuationSpacingRule { + fn meta(&self) -> &'static RuleMeta { + &META_HUGGING_PUNCTUATION_SPACING + } + fn phase(&self) -> TokenPhase { TokenPhase::PostWord } @@ -522,6 +546,30 @@ impl TokenRule for TildeSpacingRule { mod tests { use super::*; + /// 제49항: both LeadingDashSpacingRule and HuggingPunctuationSpacingRule + /// declare their metadata so the rule tracer reports section "49" instead of "?". + #[test] + fn leading_dash_spacing_rule_declares_section_49() { + let rule = LeadingDashSpacingRule; + let meta = rule.meta(); + assert_eq!( + meta.section, "49", + "LeadingDashSpacingRule must declare section 49" + ); + } + + /// 제49항: both LeadingDashSpacingRule and HuggingPunctuationSpacingRule + /// declare their metadata so the rule tracer reports section "49" instead of "?". + #[test] + fn hugging_punctuation_spacing_rule_declares_section_49() { + let rule = HuggingPunctuationSpacingRule; + let meta = rule.meta(); + assert_eq!( + meta.section, "49", + "HuggingPunctuationSpacingRule must declare section 49" + ); + } + /// 제59항: the blank after a Korean semicolon is written even when print /// runs the items together; the colon keeps print spacing (제51항 [다만 2]). #[rstest::rstest] diff --git a/libs/braillify/src/rules/token_rules/middle_korean_detector.rs b/libs/braillify/src/rules/token_rules/middle_korean_detector.rs index 3e90c9f2..1f0b7077 100644 --- a/libs/braillify/src/rules/token_rules/middle_korean_detector.rs +++ b/libs/braillify/src/rules/token_rules/middle_korean_detector.rs @@ -2,6 +2,7 @@ use crate::rules::context::EncodingMode; use crate::rules::token::Token; use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; +/// 제19항의 옛 글자표 규정을 적용할 인코딩 모드를 고르기 위해 옛 글자 문맥을 감지한다. pub struct MiddleKoreanDetectorRule; fn is_strong_middle_korean_char(c: char) -> bool { @@ -73,10 +74,10 @@ fn nearest_next_word<'a>(tokens: &'a [Token<'a>], index: usize) -> Option<&'a [c } static META: crate::rules::RuleMeta = crate::rules::RuleMeta { - section: "?", + section: "19", subsection: None, - name: "undeclared_middle_korean_detector", - standard_ref: "", + name: "middle_korean_detector", + standard_ref: "2024 Korean Braille Standard, 제19항 옛 글자", description: "중세국어 문맥 감지 후 인코딩 모드 전환", }; diff --git a/libs/braillify/src/rules/token_rules/normalize.rs b/libs/braillify/src/rules/token_rules/normalize.rs index 7d867a70..adb8de5b 100644 --- a/libs/braillify/src/rules/token_rules/normalize.rs +++ b/libs/braillify/src/rules/token_rules/normalize.rs @@ -171,13 +171,14 @@ impl TokenRule for NormalizeAsciiAngleBrackets { } } +/// 제53항의 가운뎃점·마침표 줄임표 형식에 맞추기 위해 줄임표 표현을 정규화한다. pub struct NormalizeEllipsis; static META: crate::rules::RuleMeta = crate::rules::RuleMeta { - section: "?", + section: "53", subsection: None, - name: "undeclared_ellipsis_normalization", - standard_ref: "", + name: "ellipsis_normalization", + standard_ref: "2024 Korean Braille Standard, 제53항 줄임표", description: "말줄임표 문자를 표준 형태로 정규화", }; diff --git a/libs/braillify/src/rules/token_rules/quote_attachment.rs b/libs/braillify/src/rules/token_rules/quote_attachment.rs index a162eb58..af281db3 100644 --- a/libs/braillify/src/rules/token_rules/quote_attachment.rs +++ b/libs/braillify/src/rules/token_rules/quote_attachment.rs @@ -1,6 +1,7 @@ use crate::rules::token::Token; use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; +/// 제54항이 따옴표와 묶음표 안쪽을 붙여 쓰도록 하므로 인접 어절을 연결한다. pub struct QuoteAttachmentRule; fn quote_delta(text: &str) -> i32 { @@ -56,10 +57,10 @@ fn quote_balance_before<'a>(tokens: &[Token<'a>], index: usize) -> i32 { } static META: crate::rules::RuleMeta = crate::rules::RuleMeta { - section: "?", + section: "54", subsection: None, - name: "undeclared_quote_attachment", - standard_ref: "", + name: "quote_attachment", + standard_ref: "2024 Korean Braille Standard, 제54항 묶음표 붙여 쓰기", description: "따옴표를 앞뒤 어절에 붙여 한 토큰으로 묶음", }; diff --git a/libs/braillify/src/rules/token_rules/spacing.rs b/libs/braillify/src/rules/token_rules/spacing.rs index e83007c5..056a6d18 100644 --- a/libs/braillify/src/rules/token_rules/spacing.rs +++ b/libs/braillify/src/rules/token_rules/spacing.rs @@ -2,6 +2,7 @@ use crate::rules::RuleMeta; use crate::rules::token::Token; use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; +/// 제60항이 별표와 참고표의 앞뒤를 한 칸씩 띄우도록 하므로 별표 간격을 조정한다. pub struct AsteriskSpacingRule; /// Compatibility registration for the removed auxiliary-verb normalizer. @@ -51,10 +52,10 @@ fn is_last_word_index(tokens: &[Token], index: usize) -> bool { } static META: crate::rules::RuleMeta = crate::rules::RuleMeta { - section: "?", + section: "60", subsection: None, - name: "undeclared_asterisk_spacing", - standard_ref: "", + name: "asterisk_spacing", + standard_ref: "2024 Korean Braille Standard, 제60항 별표·참고표", description: "별표 앞뒤 띄어쓰기 조정", }; diff --git a/libs/braillify/src/rules/token_rules/uppercase_passage.rs b/libs/braillify/src/rules/token_rules/uppercase_passage.rs index ed1ac663..3aa27241 100644 --- a/libs/braillify/src/rules/token_rules/uppercase_passage.rs +++ b/libs/braillify/src/rules/token_rules/uppercase_passage.rs @@ -9,6 +9,7 @@ use crate::rules::english_ueb::rule_10_12::{ use crate::rules::token::{ModeEvent, Token, WordToken}; use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; +/// RUEB 2024 §8.4의 대문자 낱말표 규정을 적용하기 위해 연속 대문자 구간을 묶는다. pub struct UppercasePassageRule; /// UEB §5.7.2 + §10.9 grade-1 decision for the capitals run @@ -232,10 +233,10 @@ fn is_korean_math_letter_list_start( } static META: crate::rules::RuleMeta = crate::rules::RuleMeta { - section: "?", + section: "8.4", subsection: None, - name: "undeclared_uppercase_passage", - standard_ref: "", + name: "uppercase_passage", + standard_ref: "RUEB 2024 §8.4", description: "연속 대문자 구간을 하나의 구절로 묶음", }; diff --git a/libs/braillify/src/rules/trace.rs b/libs/braillify/src/rules/trace.rs index 09cebc7e..99caf077 100644 --- a/libs/braillify/src/rules/trace.rs +++ b/libs/braillify/src/rules/trace.rs @@ -713,6 +713,29 @@ mod tests { assert_eq!(JamoRule::for_vowel(vowel), expected); } + #[rstest::rstest] + #[case::ellipsis_normalization("ellipsis_normalization", "53")] + #[case::inline_fraction("inline_fraction", "47")] + #[case::latex_fraction("latex_fraction", "47")] + #[case::asterisk_spacing("asterisk_spacing", "60")] + #[case::quote_attachment("quote_attachment", "54")] + #[case::historical_gloss_spacing("historical_gloss_spacing", "54")] + #[case::digital_notation("digital_notation", "35")] + #[case::uppercase_passage("uppercase_passage", "8.4")] + #[case::middle_korean_detector("middle_korean_detector", "19")] + #[case::latex_merge("latex_merge", "-")] + fn token_rules_report_their_declared_sections( + #[case] name: &str, + #[case] expected_section: &str, + ) { + let meta = registered_rules(RuleKind::Token) + .iter() + .find(|meta| meta.name == name) + .expect("declared token rule must be registered"); + + assert_eq!(meta.section, expected_section); + } + #[test] fn korean_registry_holds_no_duplicate_rule_names() { let mut names: Vec<_> = registered_rules(RuleKind::Korean) From 56243aef3adc5051c9100e0efce0a4cc341f74e0 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 20:01:34 +0900 Subject: [PATCH 009/132] Name the rule behind UEB capital and grade-1 indicators The English path wrote its indicators straight into the output without telling the tracer, so every capital sign and grade-1 sign belonged to no rule at all. `A1` left one cell unexplained and `Q50 2.2d` left three, and the landing page had to show those cells as coming from nowhere. The indicators now carry their RUEB sections: the capital-letter sign is 8.3, the capitalised-word sign is 8.4, and the grade-1 sign is 5. Spaced numeric output such as the `2.2` in `Q50 2.2d` takes 6. Recording them is not simply another attempt. `settle_word_attribution` credits a spelled-out word to 4.1 only while `attempt_count()` has not moved since the word began, so booking indicators as attempts would have silently stripped attribution from the very words they decorate. Indicators are therefore collected on their own cursor, counted separately, and subtracted from the broad fallback ranges during alignment so no cell is claimed twice. The test that pinned the gap open is now three tests that pin it shut, and a capitalised spelled-out word guards the hazard above. Attribution only: no output cell changes. Fixtures 5141 of 5141, corpus 456,025 of 467,121, marker bench 838 / 145 / 305 / 398. --- libs/braillify/src/lib.rs | 54 ++++--- .../rules/english_ueb/engine/encode_space.rs | 5 + .../rules/english_ueb/engine/encode_word.rs | 6 +- .../rules/english_ueb/engine/word_methods.rs | 31 +++- libs/braillify/src/rules/english_ueb/mod.rs | 137 +++++++++++++++--- libs/braillify/src/rules/trace.rs | 27 ++++ 6 files changed, 213 insertions(+), 47 deletions(-) diff --git a/libs/braillify/src/lib.rs b/libs/braillify/src/lib.rs index 197a0ff4..861b8917 100644 --- a/libs/braillify/src/lib.rs +++ b/libs/braillify/src/lib.rs @@ -1646,6 +1646,8 @@ mod trace_tests { #[case::math_variables("$x^2+y^2=z^2$")] #[case::math_function("$\\sin x$")] #[case::latex_fraction("$\\frac{3}{4}$")] + #[case::ueb_capital_then_digits("A1")] + #[case::ueb_capital_digits_and_decimal("Q50 2.2d")] // 제35항 numeric bridge resuming into a lowercase a-j letter: UEB 6.5.2 makes // the emitter write a continuation cell there, and it must name itself. #[case::roman_number_bridge_into_low_letter("가나 (1c) 다라")] @@ -1655,7 +1657,36 @@ mod trace_tests { assert_eq!( trace.attributed_cells(), cells.len() as u32, - "unattributed cells in {input:?}: {:?}", + "unattributed cells in {input:?} with output {cells:?}: {:?}", + trace.events() + ); + } + + #[test] + fn capitalised_spelled_word_keeps_letter_attribution() { + let (cells, trace) = encode_with_trace("MP3").expect("input must encode"); + + assert_eq!(trace.attributed_cells(), cells.len() as u32); + let letter_cells = trace + .events() + .iter() + .filter(|event| event.rule.meta().is_some_and(|meta| meta.section == "4.1")) + .map(|event| event.output.end - event.output.start) + .sum::(); + assert_eq!(letter_cells, 2, "only M and P belong to the letter rule"); + } + + #[rstest::rstest] + #[case::ethene_hydration("C_{2}H_{4}(g) + H_{2}O(g) -> C_{2}H_{5}OH(g)")] + #[case::aluminium_ion("Al3+(aq) + 3e- -> Al(s)")] + #[case::water_formation("2H_{2}(g) + O_{2}(g) -> 2H_{2}O(g)")] + fn chemical_equation_cells_are_accounted_for(#[case] input: &str) { + let (cells, trace) = encode_with_trace(input).expect("input must encode"); + + assert_eq!( + trace.attributed_cells(), + cells.len() as u32, + "unattributed cells in {input:?} with output {cells:?}: {:?}", trace.events() ); } @@ -1682,26 +1713,6 @@ mod trace_tests { ); } - /// The capitals and grade-1 indicators are written straight into the output - /// by the word encoder, while UEB attribution places whole *attempts* of the - /// contraction search — so an indicator belongs to no attempt and stays - /// unexplained. A Korean document never reaches this: the whole 467k-sentence - /// corpus leaves no cell unexplained, and only one sentence in it takes the - /// UEB path at all. Pinned to the exact counts so the gap cannot widen while - /// unnoticed, and so closing it shows up here as a failure to update. - #[rstest::rstest] - #[case::capital_then_digits("A1", 1)] - #[case::capital_digits_and_decimal("Q50 2.2d", 3)] - fn the_ueb_only_path_still_leaves_its_indicators_unexplained( - #[case] input: &str, - #[case] expected: u32, - ) { - let (_, trace) = encode_with_trace(input).expect("input must encode"); - - assert_eq!(trace.path(), TracePath::EnglishUeb); - assert_eq!(trace.unattributed_cells(), expected); - } - /// Every rule must name a cell range that is really its own, so a cell may /// never be claimed by two rules at once. #[rstest::rstest] @@ -1710,6 +1721,7 @@ mod trace_tests { #[case::measurement("3kg 5%")] #[case::english("the child was here")] #[case::math("3+4=7")] + #[case::chemical("C_{2}H_{4}(g) + H_{2}O(g) -> C_{2}H_{5}OH(g)")] fn no_cell_is_claimed_twice(#[case] input: &str) { let (cells, trace) = encode_with_trace(input).expect("input must encode"); diff --git a/libs/braillify/src/rules/english_ueb/engine/encode_space.rs b/libs/braillify/src/rules/english_ueb/engine/encode_space.rs index 6ddb32d8..75dcf4b2 100644 --- a/libs/braillify/src/rules/english_ueb/engine/encode_space.rs +++ b/libs/braillify/src/rules/english_ueb/engine/encode_space.rs @@ -30,12 +30,17 @@ macro_rules! encode_space_arm { } if is_numeric_space($tokens, $i) { $numeric_separator_count += 1; + let numeric_space_start = $out.len(); $skip_to = encode_following_number_as_numeric_space( $tokens, $i, &mut $out, $numeric_separator_count == 6, )?; + super::record_whole_word( + super::UebMoveSource::Numeric, + &$out[numeric_space_start..], + ); $prev_was_number = true; $numeric_mode = true; $line_mode_active = false; diff --git a/libs/braillify/src/rules/english_ueb/engine/encode_word.rs b/libs/braillify/src/rules/english_ueb/engine/encode_word.rs index 6b67ebd3..73f48784 100644 --- a/libs/braillify/src/rules/english_ueb/engine/encode_word.rs +++ b/libs/braillify/src/rules/english_ueb/engine/encode_word.rs @@ -270,7 +270,11 @@ macro_rules! encode_word_arm { .first() .is_some_and(|c| c.is_ascii_lowercase() && ('a'..='j').contains(c)) { - $out.push(GRADE1); + super::push_indicator( + &mut $out, + super::UebMoveSource::Grade1Indicator, + &[GRADE1], + ); } encode_literal_word($chars, &mut $out)?; } diff --git a/libs/braillify/src/rules/english_ueb/engine/word_methods.rs b/libs/braillify/src/rules/english_ueb/engine/word_methods.rs index d6cdefae..f26b2fe7 100644 --- a/libs/braillify/src/rules/english_ueb/engine/word_methods.rs +++ b/libs/braillify/src/rules/english_ueb/engine/word_methods.rs @@ -84,13 +84,21 @@ impl EnglishUebEngine { ); } if shortform_usable && super::super::rule_10_9::is_pure_shortform_abbreviation(&word) { - out.push(GRADE1); + super::super::push_indicator( + out, + super::super::UebMoveSource::Grade1Indicator, + &[GRADE1], + ); } // Inside a §8.4 passage the ⠠⠠⠠ … ⠠⠄ carry capitalisation; `?` still guards // any residual mixed-case word there (→ legacy fallback). if !suppress_caps && !digit_adjacent && chemical_formula_caps(chars) { for &c in chars { - out.push(CAPITAL); + super::super::push_indicator( + out, + super::super::UebMoveSource::CapitalLetterIndicator, + &[CAPITAL], + ); out.push(crate::english::encode_english(c.to_ascii_lowercase()).ok()?); } return Some(()); @@ -98,7 +106,11 @@ impl EnglishUebEngine { match classify_caps(chars)? { _ if suppress_caps => {} Caps::None => {} - Caps::Single => out.push(CAPITAL), + Caps::Single => super::super::push_indicator( + out, + super::super::UebMoveSource::CapitalLetterIndicator, + &[CAPITAL], + ), Caps::Word => { // §8.7 / UEB §5.7.2: a *standing-alone* all-caps acronym whose // lowercase letters form a multi-letter shortform (e.g. `CD` = @@ -113,10 +125,17 @@ impl EnglishUebEngine { && !super::super::rule_10_9::is_pure_shortform_abbreviation(&word) && crate::rules::english_shortform::requires_grade1_indicator(&uppercase_word) { - out.push(GRADE1); + super::super::push_indicator( + out, + super::super::UebMoveSource::Grade1Indicator, + &[GRADE1], + ); } - out.push(CAPITAL); - out.push(CAPITAL); + super::super::push_indicator( + out, + super::super::UebMoveSource::CapitalisedWordIndicator, + &[CAPITAL, CAPITAL], + ); } } // §10.12.1: an all-caps initialism directly abutting a digit (`CH6`, diff --git a/libs/braillify/src/rules/english_ueb/mod.rs b/libs/braillify/src/rules/english_ueb/mod.rs index ce8111fb..918d9d57 100644 --- a/libs/braillify/src/rules/english_ueb/mod.rs +++ b/libs/braillify/src/rules/english_ueb/mod.rs @@ -56,20 +56,30 @@ pub mod token; use engine::EnglishUebEngine; thread_local! { - /// One entry per completed word-encoding attempt, in the order the engine - /// made them. The engine encodes a word under several constraint - /// combinations and keeps one, so most entries describe output that was - /// thrown away; [`align_selected`] separates the kept attempt from the rest. - static ATTEMPTS: std::cell::RefCell>> = + /// Word attempts and structural indicators, in emission order. The engine + /// encodes a word under several constraint combinations and keeps one, so + /// [`align_selected`] separates kept attempts from discarded ones while + /// retaining indicators emitted directly into the selected output. + static ATTRIBUTIONS: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; } +enum AttributionRecord { + Word(WordAttempt), + Indicator(IndicatorAttempt), +} + /// The cells one attempt produced, plus where each rule's cells sat inside them. struct WordAttempt { cells: Vec, moves: Vec<(crate::rules::trace::RuleId, u32, u32)>, } +struct IndicatorAttempt { + cells: Vec, + rule: crate::rules::trace::RuleId, +} + /// Accumulates the moves of one word-encoding attempt. /// /// Offsets are taken against the attempt's own output as it is built, because @@ -84,7 +94,7 @@ pub(super) struct AttemptRecorder { impl AttemptRecorder { pub(super) fn new() -> Self { - let collecting = ATTEMPTS.with(|slot| slot.borrow().is_some()); + let collecting = ATTRIBUTIONS.with(|slot| slot.borrow().is_some()); Self { moves: collecting.then(Vec::new), } @@ -100,14 +110,14 @@ impl AttemptRecorder { let Some(moves) = self.moves else { return; }; - ATTEMPTS.with(|slot| { + ATTRIBUTIONS.with(|slot| { if let Ok(mut slot) = slot.try_borrow_mut() - && let Some(attempts) = slot.as_mut() + && let Some(records) = slot.as_mut() { - attempts.push(WordAttempt { + records.push(AttributionRecord::Word(WordAttempt { cells: cells.to_vec(), moves, - }); + })); } }); } @@ -142,13 +152,13 @@ pub(crate) type UebSpan = (crate::rules::trace::RuleId, core::ops::Range); fn collect_selected( encode: impl FnOnce() -> Option>, -) -> Option<(Vec, Vec)> { - ATTEMPTS.with(|slot| *slot.borrow_mut() = Some(Vec::new())); +) -> Option<(Vec, Vec)> { + ATTRIBUTIONS.with(|slot| *slot.borrow_mut() = Some(Vec::new())); let encoded = encode(); - let attempts = ATTEMPTS + let records = ATTRIBUTIONS .with(|slot| slot.borrow_mut().take()) .unwrap_or_default(); - encoded.map(|cells| (cells, attempts)) + encoded.map(|cells| (cells, records)) } /// Place each attempt's moves in the finished output, skipping attempts the @@ -157,20 +167,44 @@ fn collect_selected( /// The scan only moves forward, so an attempt is matched at or after everything /// already placed. A discarded attempt is recognised by its cells not appearing /// there — the engine never emitted them. -fn align_selected(cells: &[u8], attempts: &[WordAttempt]) -> Vec { +fn align_selected(cells: &[u8], records: &[AttributionRecord]) -> Vec { + let mut indicator_spans = Vec::new(); + let mut indicator_cursor = 0usize; + for record in records { + match record { + AttributionRecord::Word(_) => {} + AttributionRecord::Indicator(indicator) => { + if let Some(base) = find_from(cells, &indicator.cells, indicator_cursor) { + let end = base + indicator.cells.len(); + indicator_spans.push((indicator.rule, base as u32..end as u32)); + indicator_cursor = end; + } + } + } + } + let mut spans = Vec::new(); let mut cursor = 0usize; - for attempt in attempts { + for record in records { + let attempt = match record { + AttributionRecord::Word(attempt) => attempt, + AttributionRecord::Indicator(_) => continue, + }; let Some(base) = find_from(cells, &attempt.cells, cursor) else { continue; }; for (rule, offset, len) in &attempt.moves { let start = base + *offset as usize; let end = start + *len as usize; - spans.push((*rule, start as u32..end as u32)); + push_without_indicators( + &mut spans, + (*rule, start as u32..end as u32), + &indicator_spans, + ); } cursor = base + attempt.cells.len(); } + spans.extend(indicator_spans); // An empty cell between words is the inter-word blank, the same structural // output the Korean emitter accounts for. It carries no dots, so there is no // other thing it could be. @@ -183,6 +217,26 @@ fn align_selected(cells: &[u8], attempts: &[WordAttempt]) -> Vec { spans } +fn push_without_indicators(spans: &mut Vec, candidate: UebSpan, indicators: &[UebSpan]) { + let (rule, range) = candidate; + let mut start = range.start; + for (_, indicator) in indicators { + if indicator.end <= start { + continue; + } + if indicator.start >= range.end { + break; + } + if start < indicator.start { + spans.push((rule, start..indicator.start)); + } + start = start.max(indicator.end); + } + if start < range.end { + spans.push((rule, start..range.end)); + } +} + fn find_from(haystack: &[u8], needle: &[u8], from: usize) -> Option { if needle.is_empty() || from + needle.len() > haystack.len() { return None; @@ -208,10 +262,13 @@ pub(crate) enum UebMoveSource { LowerWordsign = 5, Numeric = 6, Symbol = 7, + Grade1Indicator = 8, + CapitalLetterIndicator = 9, + CapitalisedWordIndicator = 10, } /// Number of non-rule slots reserved before the contraction rules. -pub(crate) const UEB_RESERVED_SLOTS: usize = 8; +pub(crate) const UEB_RESERVED_SLOTS: usize = 11; /// Record a whole word that a lookup table resolved in one step, bypassing the /// contraction search. Without this a wordsign or shortform would leave its @@ -219,7 +276,14 @@ pub(crate) const UEB_RESERVED_SLOTS: usize = 8; /// How many attempts have been recorded so far, so a caller can tell whether the /// encoder it just ran attributed its own output. pub(super) fn attempt_count() -> usize { - ATTEMPTS.with(|slot| slot.borrow().as_ref().map_or(0, Vec::len)) + ATTRIBUTIONS.with(|slot| { + slot.borrow().as_ref().map_or(0, |records| { + records + .iter() + .filter(|record| matches!(record, AttributionRecord::Word(_))) + .count() + }) + }) } /// A word whose attribution has not been settled yet: where its cells start in @@ -251,6 +315,20 @@ pub(super) fn record_whole_word(source: UebMoveSource, cells: &[u8]) { attempt.finish(cells); } +pub(super) fn push_indicator(out: &mut Vec, source: UebMoveSource, cells: &[u8]) { + out.extend_from_slice(cells); + ATTRIBUTIONS.with(|slot| { + if let Ok(mut slot) = slot.try_borrow_mut() + && let Some(records) = slot.as_mut() + { + records.push(AttributionRecord::Indicator(IndicatorAttempt { + cells: cells.to_vec(), + rule: crate::rules::trace::RuleId::ueb(source as usize), + })); + } + }); +} + static UEB_NON_RULE_METAS: [crate::rules::RuleMeta; UEB_RESERVED_SLOTS] = [ crate::rules::RuleMeta { section: "10.9", @@ -308,6 +386,27 @@ static UEB_NON_RULE_METAS: [crate::rules::RuleMeta; UEB_RESERVED_SLOTS] = [ standard_ref: "UEB 2024 §3", description: "General symbol such as percent, ampersand or asterisk", }, + crate::rules::RuleMeta { + section: "5", + subsection: None, + name: "ueb_grade1_indicator", + standard_ref: "RUEB 2024 §5", + description: "Grade-1 indicator establishing grade-1 mode", + }, + crate::rules::RuleMeta { + section: "8.3", + subsection: None, + name: "ueb_capital_letter_indicator", + standard_ref: "RUEB 2024 §8.3", + description: "Capital indicator applying to the following letter", + }, + crate::rules::RuleMeta { + section: "8.4", + subsection: None, + name: "ueb_capitalised_word_indicator", + standard_ref: "RUEB 2024 §8.4", + description: "Capital indicators applying to the following word", + }, ]; /// Metadata of every UEB move source, in [`crate::rules::trace::RuleId`] order: diff --git a/libs/braillify/src/rules/trace.rs b/libs/braillify/src/rules/trace.rs index 99caf077..8ea8cc87 100644 --- a/libs/braillify/src/rules/trace.rs +++ b/libs/braillify/src/rules/trace.rs @@ -651,6 +651,33 @@ mod tests { ); } + #[rstest::rstest] + #[case::grade1( + crate::rules::english_ueb::UebMoveSource::Grade1Indicator, + "ueb_grade1_indicator", + "5" + )] + #[case::capital_letter( + crate::rules::english_ueb::UebMoveSource::CapitalLetterIndicator, + "ueb_capital_letter_indicator", + "8.3" + )] + #[case::capitalised_word( + crate::rules::english_ueb::UebMoveSource::CapitalisedWordIndicator, + "ueb_capitalised_word_indicator", + "8.4" + )] + fn ueb_indicator_slots_resolve_to_their_metadata( + #[case] slot: crate::rules::english_ueb::UebMoveSource, + #[case] name: &str, + #[case] section: &str, + ) { + let id = RuleId::ueb(slot as usize); + assert_eq!(id.kind(), Some(RuleKind::EnglishUeb)); + assert_eq!(id.meta().map(|meta| meta.name), Some(name)); + assert_eq!(id.meta().map(|meta| meta.section), Some(section)); + } + #[test] fn unattributed_has_no_metadata_and_no_kind() { assert_eq!(RuleId::UNATTRIBUTED.meta(), None); From 37a26a7adaad45f512918921bc894269aa175b5e Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 21:13:01 +0900 Subject: [PATCH 010/132] Name the article behind every math symbol branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The math engine answered the tracer with a placeholder 2,378 times over 5,160 traced sentences. One rule, MathSymbolRule, dispatches more than thirty symbols to as many different articles, so a single article per rule could never be honest: the arrow arm follows 제10항, the set arm 제60항, the quantifier arm 제61항, and one number had to stand for all of them. A rule may now own several registry slots. It declares the extra articles in variant_metas() and returns the one it actually used through ConsumedWithMeta, carrying the static itself rather than an index that would change meaning the moment the list is reordered. Dispatch resolves it by pointer identity within that rule's own declarations and refuses anything undeclared, so a rule cannot borrow a neighbour's article by accident. The flattening forced a second fix. Ids came from the rule's physical position in the dispatch vector, which stops matching the registry as soon as one rule occupies several slots; every rule after MathSymbolRule would have resolved to someone else's metadata. Dispatch now tracks the flattened base instead. The 221 shortcut characters carry their article in the same record as their cells, so the two cannot drift apart. Grouping them by article also made the table say which rule governs what, which it never did before. Seven symbols keep the honest placeholder because the standard does not name them. Two comments were simply wrong and are corrected: 제26항 is 행렬, not the product sign, and @9 is 제61항 1 부정, not 닮음, which is ,' under 제42항. Every one of the 221 character-to-cell mappings is unchanged, checked pair by pair against the previous table, and output bytes are identical across the fixtures and all 467,121 corpus rows. Fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. Placeholders fall from 2,378 to 11. --- libs/braillify/src/math_symbol_shortcut.rs | 781 ++++++++++++------ libs/braillify/src/rules/math/encoder.rs | 147 +++- .../src/rules/math/encoder/symbol_rule.rs | 118 ++- .../src/rules/math/math_token_rule.rs | 273 +++++- 4 files changed, 1042 insertions(+), 277 deletions(-) diff --git a/libs/braillify/src/math_symbol_shortcut.rs b/libs/braillify/src/math_symbol_shortcut.rs index 4baf0c0e..c180abc9 100644 --- a/libs/braillify/src/math_symbol_shortcut.rs +++ b/libs/braillify/src/math_symbol_shortcut.rs @@ -1,250 +1,520 @@ use phf::phf_map; +use crate::rules::RuleMeta; +use crate::rules::math::math_token_rule::UNDECLARED_MATH_RULE; use crate::unicode::decode_unicode; -static SHORTCUT_MAP: phf::Map = phf_map! { - // PDF 한국 점자 규정 (수학) — 동그라미 숫자 ①②③④⑤⑥⑦⑧⑨⑩ - '\u{2460}' => &[decode_unicode('⠼'), decode_unicode('⠂')], // ① - '\u{2461}' => &[decode_unicode('⠼'), decode_unicode('⠆')], // ② - '\u{2462}' => &[decode_unicode('⠼'), decode_unicode('⠒')], // ③ - '\u{2463}' => &[decode_unicode('⠼'), decode_unicode('⠲')], // ④ - '\u{2464}' => &[decode_unicode('⠼'), decode_unicode('⠢')], // ⑤ - '\u{2465}' => &[decode_unicode('⠼'), decode_unicode('⠖')], // ⑥ - '\u{2466}' => &[decode_unicode('⠼'), decode_unicode('⠶')], // ⑦ - '\u{2467}' => &[decode_unicode('⠼'), decode_unicode('⠦')], // ⑧ - '\u{2468}' => &[decode_unicode('⠼'), decode_unicode('⠔')], // ⑨ - '\u{2469}' => &[decode_unicode('⠼'), decode_unicode('⠴')], // ⑩ - '+' => &[decode_unicode('⠢')], // 5 (덧셈표) - '/' => &[decode_unicode('⠸'), decode_unicode('⠌')], // _/ (분수 기호) - '\u{2212}' => &[decode_unicode('⠔')], // 9 (뺄셈표) - '\u{00D7}' => &[decode_unicode('⠡')], // * (곱셈표) - '\u{00F7}' => &[decode_unicode('⠌'), decode_unicode('⠌')], // // (나눗셈표) - '=' => &[decode_unicode('⠒'), decode_unicode('⠒')], // 33 (등호) - '>' => &[decode_unicode('⠢'), decode_unicode('⠢')], // 55 (보다크다) - '<' => &[decode_unicode('⠔'), decode_unicode('⠔')], // 99 (보다작다) - '\u{2260}' => &[decode_unicode('⠨'), decode_unicode('⠒'), decode_unicode('⠒')], // .33 (같지않다) - '\u{2265}' => &[decode_unicode('⠲'), decode_unicode('⠲')], // 44 (크거나같다) - '\u{2267}' => &[decode_unicode('⠲'), decode_unicode('⠲')], // 44 (크거나같다) - '\u{2264}' => &[decode_unicode('⠖'), decode_unicode('⠖')], // 66 (작거나같다) - '\u{2266}' => &[decode_unicode('⠖'), decode_unicode('⠖')], // 66 (작거나같다) - '\u{2252}' => &[decode_unicode('⠐'), decode_unicode('⠒'), decode_unicode('⠒')], // "33 (근삿값) - '\u{2236}' => &[decode_unicode('⠐'), decode_unicode('⠂')], // "1 (비) - '\u{2192}' => &[decode_unicode('⠒'), decode_unicode('⠕')], // 3o (오른쪽 화살표) - '\u{2190}' => &[decode_unicode('⠪'), decode_unicode('⠒')], // [3 (왼쪽 화살표) - '\u{2194}' => &[decode_unicode('⠪'), decode_unicode('⠒'), decode_unicode('⠕')], // [3o (양쪽 화살표) - '\u{2191}' => &[decode_unicode('⠰'), decode_unicode('⠒'), decode_unicode('⠕')], // ;3o (위쪽 화살표) - '\u{2193}' => &[decode_unicode('⠘'), decode_unicode('⠒'), decode_unicode('⠕')], // ^3o (아래쪽 화살표) - '\u{21D2}' => &[decode_unicode('⠒'), decode_unicode('⠒'), decode_unicode('⠕')], // 33o (항진명제) - '\u{21D4}' => &[decode_unicode('⠪'), decode_unicode('⠒'), decode_unicode('⠒'), decode_unicode('⠕')], // [33o (필요충분) - '\u{21C4}' => &[decode_unicode('⠪'), decode_unicode('⠶'), decode_unicode('⠕')], // [7o (동치명제) - '\u{2032}' => &[decode_unicode('⠤')], // - (프라임) - '\u{2033}' => &[decode_unicode('⠤'), decode_unicode('⠤')], // -- (더블 프라임, PDF 제17항) - '\u{2034}' => &[decode_unicode('⠤'), decode_unicode('⠤'), decode_unicode('⠤')], // --- (트리플 프라임) - '\u{00B2}' => &[decode_unicode('⠘'), decode_unicode('⠼'), decode_unicode('⠃')], // ^#b (제곱) - '\u{00B3}' => &[decode_unicode('⠘'), decode_unicode('⠼'), decode_unicode('⠉')], // ^#c (세제곱) - '\u{2074}' => &[decode_unicode('⠘'), decode_unicode('⠼'), decode_unicode('⠙')], // ^#d (네제곱) - '\u{2075}' => &[decode_unicode('⠘'), decode_unicode('⠼'), decode_unicode('⠑')], // ^#e (오제곱) - '\u{2077}' => &[decode_unicode('⠘'), decode_unicode('⠼'), decode_unicode('⠛')], // ^#g (칠제곱) - '\u{2079}' => &[decode_unicode('⠘'), decode_unicode('⠼'), decode_unicode('⠊')], // ^#i (구제곱) - '\u{00B9}' => &[decode_unicode('⠘'), decode_unicode('⠼'), decode_unicode('⠁')], // ^#a (1제곱) - '\u{2070}' => &[decode_unicode('⠘'), decode_unicode('⠼'), decode_unicode('⠚')], // ^#j (0제곱) - '\u{1D4F}' => &[decode_unicode('⠘'), decode_unicode('⠅')], // ^k (위첨자 k) - '\u{1D50}' => &[decode_unicode('⠘'), decode_unicode('⠍')], // ^m (위첨자 m) - '\u{02E3}' => &[decode_unicode('⠘'), decode_unicode('⠭')], // ^x (위첨자 x) - '\u{207D}' => &[decode_unicode('⠘'), decode_unicode('⠦')], // ^8 (위첨자 () - '\u{207E}' => &[decode_unicode('⠴')], // 0 (위첨자 )) - '\u{207F}' => &[decode_unicode('⠘'), decode_unicode('⠝')], // ^n (위첨자 n) - '\u{207B}' => &[decode_unicode('⠘'), decode_unicode('⠔')], // ^9 (위첨자 마이너스) - '\u{207A}' => &[decode_unicode('⠘'), decode_unicode('⠢')], // ^5 (위첨자 플러스) - '\u{2080}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠚')], // ;#j (아래첨자 0) - '\u{2081}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠁')], // ;#a (아래첨자 1) - '\u{2082}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠃')], // ;#b (아래첨자 2) - '\u{2083}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠉')], // ;#c (아래첨자 3) - '\u{2084}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠙')], // ;#d (아래첨자 4) - '\u{2085}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠑')], // ;#e (아래첨자 5) - '\u{2086}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠋')], // ;#f (아래첨자 6) - '\u{2087}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠛')], // ;#g (아래첨자 7) - '\u{2088}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠓')], // ;#h (아래첨자 8) - '\u{2089}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠊')], // ;#i (아래첨자 9) - '\u{208D}' => &[decode_unicode('⠰'), decode_unicode('⠦')], // ;8 (아래첨자 () - '\u{208E}' => &[decode_unicode('⠴')], // 0 (아래첨자 )) - '\u{2090}' => &[decode_unicode('⠰'), decode_unicode('⠁')], // ;a (아래첨자 a) - '\u{2098}' => &[decode_unicode('⠰'), decode_unicode('⠍')], // ;m (아래첨자 m) - '\u{2093}' => &[decode_unicode('⠰'), decode_unicode('⠭')], // ;x (아래첨자 x) - '\u{2099}' => &[decode_unicode('⠰'), decode_unicode('⠝')], // ;n (아래첨자 n) - '\u{208A}' => &[decode_unicode('⠰'), decode_unicode('⠢')], // ;5 (아래첨자 +) - '\u{2044}' => &[decode_unicode('⠌')], // / (분수 슬래시) - '\u{2500}' => &[decode_unicode('⠌')], // ─ (괘선 — PDF 제7항 분수선 기호 형태) - '\u{2E29}' => &[decode_unicode('⠄')], // open-ended right delimiter (`\right.`) - '_' => &[decode_unicode('⠠'), decode_unicode('⠤')], // 밑줄 marker (PDF 제23항 2) - '\u{0332}' => &[decode_unicode('⠠'), decode_unicode('⠤')], // ̲ (combining low line — 밑줄 결합부호) - '|' => &[decode_unicode('⠳')], // | (절댓값) - '\u{00AC}' => &[decode_unicode('⠈'), decode_unicode('⠔')], // @9 (부정) - '\u{00B0}' => &[decode_unicode('⠴'), decode_unicode('⠙')], // 0d (도) - '\u{00B1}' => &[decode_unicode('⠢'), decode_unicode('⠔')], // ± (PDF 제2항 — plus-minus) - '\u{00B7}' => &[decode_unicode('⠐')], // " (점 곱셈) - '…' => &[decode_unicode('⠠'), decode_unicode('⠠'), decode_unicode('⠠')], // ,,, (줄임표) - '⋯' => &[decode_unicode('⠠'), decode_unicode('⠠'), decode_unicode('⠠')], // ,,, (줄임표) - '\u{221A}' => &[decode_unicode('⠜')], // > (근호) - '\u{2224}' => &[decode_unicode('⠨'), decode_unicode('⠳')], // .\ (나누어떨어지지않는다) - '\u{2220}' => &[decode_unicode('⠹')], // ? (각) - '\u{22A5}' => &[decode_unicode('⠴'), decode_unicode('⠄')], // 0' (수직) - '\u{2225}' => &[decode_unicode('⠰'), decode_unicode('⠆')], // ;2 (평행) - '\u{2AFD}' => &[decode_unicode('⠰'), decode_unicode('⠆')], // ;2 (평행) - '\u{223D}' => &[decode_unicode('⠠'), decode_unicode('⠄')], // ,' (닮음) - '\u{2261}' => &[decode_unicode('⠶'), decode_unicode('⠶')], // 77 (합동) - '\u{221E}' => &[decode_unicode('⠿')], // = (무한대) - '\u{222B}' => &[decode_unicode('⠮')], // ! (부정적분) - '\u{222E}' => &[decode_unicode('⠾')], // ) (선적분) - '\u{222C}' => &[decode_unicode('⠮'), decode_unicode('⠮')], // !! (이중적분) - '\u{2207}' => &[decode_unicode('⠸'), decode_unicode('⠩')], // _% (델연산자) - '\u{2202}' => &[decode_unicode('⠫')], // $ (편도함수) - '\u{2208}' => &[decode_unicode('⠖')], // 6 (원소 왼쪽) - '\u{220B}' => &[decode_unicode('⠲')], // 4 (원소 오른쪽) - '\u{2209}' => &[decode_unicode('⠨'), decode_unicode('⠖')], // .6 (원소 아닌) - '\u{220C}' => &[decode_unicode('⠨'), decode_unicode('⠲')], // .4 (원소아닌 오른쪽) - '\u{2282}' => &[decode_unicode('⠖'), decode_unicode('⠂')], // 61 (부분집합 왼쪽) - '\u{2283}' => &[decode_unicode('⠐'), decode_unicode('⠲')], // "4 (부분집합 오른쪽) - '\u{2284}' => &[decode_unicode('⠨'), decode_unicode('⠖'), decode_unicode('⠂')], // .61 (부분집합 아님) - '\u{2285}' => &[decode_unicode('⠨'), decode_unicode('⠐'), decode_unicode('⠲')], // ."4 (부분집합 아님) - '\u{2205}' => &[decode_unicode('⠨'), decode_unicode('⠋')], // .f (공집합) - '\u{222A}' => &[decode_unicode('⠬')], // + (합집합) - '\u{2229}' => &[decode_unicode('⠩')], // % (교집합) - '\u{2200}' => &[decode_unicode('⠨'), decode_unicode('⠄')], // .' (모든) - '\u{2203}' => &[decode_unicode('⠨'), decode_unicode('⠢')], // .5 (존재하는) - '\u{2204}' => &[decode_unicode('⠨'), decode_unicode('⠨'), decode_unicode('⠢')], // ..5 (존재하지 않는) - '\u{2227}' => &[decode_unicode('⠹')], // ? (논리곱) - '\u{2228}' => &[decode_unicode('⠼')], // # (논리합) - '\u{22BB}' => &[decode_unicode('⠼'), decode_unicode('⠤')], // #- (배타적 논리합) - '\u{2234}' => &[decode_unicode('⠠'), decode_unicode('⠡')], // ,* (그러므로) - '\u{2235}' => &[decode_unicode('⠈'), decode_unicode('⠌')], // @/ (왜냐하면) - '\u{2248}' => &[decode_unicode('⠈'), decode_unicode('⠔'), decode_unicode('⠈'), decode_unicode('⠔')], // @9@9 (이중물결) - '\u{224A}' => &[decode_unicode('⠈'), decode_unicode('⠔'), decode_unicode('⠈'), decode_unicode('⠔'), decode_unicode('⠒')], // @9@93 (이중물결 아래줄) - '\u{2243}' => &[decode_unicode('⠈'), decode_unicode('⠔'), decode_unicode('⠒')], // @93 (물결 아래줄) - '\u{2245}' => &[decode_unicode('⠈'), decode_unicode('⠔'), decode_unicode('⠒'), decode_unicode('⠒')], // @933 (물결아래등호) - '\u{2241}' => &[decode_unicode('⠨'), decode_unicode('⠈'), decode_unicode('⠔')], // .@9 (not sim) - '\u{226E}' => &[decode_unicode('⠨'), decode_unicode('⠔'), decode_unicode('⠔')], // .99 (보다작지않다) - '\u{226F}' => &[decode_unicode('⠨'), decode_unicode('⠢'), decode_unicode('⠢')], // .55 (보다크지않다) - '\u{2270}' => &[decode_unicode('⠨'), decode_unicode('⠖'), decode_unicode('⠖')], // .66 (작거나같지않다) - '\u{2271}' => &[decode_unicode('⠨'), decode_unicode('⠲'), decode_unicode('⠲')], // .44 (크거나같지않다) - '\u{25B7}' => &[decode_unicode('⠸'), decode_unicode('⠜')], // _> (오른쪽 세모꼴) - '\u{25C1}' => &[decode_unicode('⠸'), decode_unicode('⠣')], // _< (왼쪽 세모꼴) - '\u{25A1}' => &[decode_unicode('⠸'), decode_unicode('⠶')], // _7 (네모) - '\u{25B3}' => &[decode_unicode('⠸'), decode_unicode('⠬')], // _+ (세모) - '\u{25B1}' => &[decode_unicode('⠸'), decode_unicode('⠌'), decode_unicode('⠌')], // _// (평행사변형) - '\u{23E2}' => &[decode_unicode('⠸'), decode_unicode('⠌'), decode_unicode('⠡')], // _/* (사다리꼴) - '\u{2302}' => &[decode_unicode('⠸'), decode_unicode('⠪'), decode_unicode('⠅')], // _[k (집) - '\u{2394}' => &[decode_unicode('⠸'), decode_unicode('⠪'), decode_unicode('⠕')], // _[o (기하 기호) - '\u{29BE}' => &[decode_unicode('⠸'), decode_unicode('⠴'), decode_unicode('⠴')], // _00 (원안점) - '\u{03A3}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠎')], // ,.s (총합) - '\u{2295}' => &[decode_unicode('⠸'), decode_unicode('⠢')], // _5 (동그라미 덧셈표) - '\u{2296}' => &[decode_unicode('⠸'), decode_unicode('⠔')], // _9 (동그라미 뺄셈표) - '\u{2297}' => &[decode_unicode('⠸'), decode_unicode('⠡')], // _* (동그라미 곱셈표) - '\u{2217}' => &[decode_unicode('⠸'), decode_unicode('⠣')], // _< (별표) - '\u{2218}' => &[decode_unicode('⠸'), decode_unicode('⠴')], // _0 (동그라미) - '\u{03B1}' => &[decode_unicode('⠨'), decode_unicode('⠁')], // .a (알파) - '\u{03B2}' => &[decode_unicode('⠨'), decode_unicode('⠃')], // .b (베타) - '\u{03B3}' => &[decode_unicode('⠨'), decode_unicode('⠛')], // .g (감마) - '\u{03B4}' => &[decode_unicode('⠨'), decode_unicode('⠙')], // .d (델타) - '\u{03B5}' => &[decode_unicode('⠨'), decode_unicode('⠑')], // .e (엡실론) - '\u{03B6}' => &[decode_unicode('⠨'), decode_unicode('⠵')], // .z (제타) - '\u{03B7}' => &[decode_unicode('⠨'), decode_unicode('⠱')], // .: (에타) - '\u{03B8}' => &[decode_unicode('⠨'), decode_unicode('⠹')], // .? (세타) - '\u{03B9}' => &[decode_unicode('⠨'), decode_unicode('⠊')], // .i (요타) - '\u{03BA}' => &[decode_unicode('⠨'), decode_unicode('⠅')], // .k (카파) - '\u{03BB}' => &[decode_unicode('⠨'), decode_unicode('⠇')], // .l (람다) - '\u{03BC}' => &[decode_unicode('⠨'), decode_unicode('⠍')], // .m (뮤) - '\u{03BD}' => &[decode_unicode('⠨'), decode_unicode('⠝')], // .n (뉴) - '\u{03BE}' => &[decode_unicode('⠨'), decode_unicode('⠭')], // .x (크시) - '\u{03BF}' => &[decode_unicode('⠨'), decode_unicode('⠕')], // .o (오미크론) - '\u{03C0}' => &[decode_unicode('⠨'), decode_unicode('⠏')], // .p (파이) - '\u{03C1}' => &[decode_unicode('⠨'), decode_unicode('⠗')], // .r (로) - '\u{03C3}' => &[decode_unicode('⠨'), decode_unicode('⠎')], // .s (시그마) - '\u{03C4}' => &[decode_unicode('⠨'), decode_unicode('⠞')], // .t (타우) - '\u{03C5}' => &[decode_unicode('⠨'), decode_unicode('⠥')], // .u (입실론) - '\u{03C6}' => &[decode_unicode('⠨'), decode_unicode('⠋')], // .f (피) - '\u{03C7}' => &[decode_unicode('⠨'), decode_unicode('⠯')], // .& (키) - '\u{03C8}' => &[decode_unicode('⠨'), decode_unicode('⠽')], // .y (프시) - '\u{03C9}' => &[decode_unicode('⠨'), decode_unicode('⠺')], // .w (오메가) - '\u{0391}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠁')], // ,.a (대문자 알파) - '\u{0392}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠃')], // ,.b (대문자 베타) - '\u{0393}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠛')], // ,.g (대문자 감마) - '\u{0395}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠑')], // ,.e (대문자 엡실론) - '\u{0396}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠵')], // ,.z (대문자 제타) - '\u{0397}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠱')], // ,.: (대문자 에타) - '\u{0398}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠹')], // ,.? (대문자 세타) - '\u{0399}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠊')], // ,.i (대문자 요타) - '\u{039A}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠅')], // ,.k (대문자 카파) - '\u{039B}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠇')], // ,.l (대문자 람다) - '\u{039C}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠍')], // ,.m (대문자 뮤) - '\u{039D}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠝')], // ,.n (대문자 뉴) - '\u{039E}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠭')], // ,.x (대문자 크시) - '\u{039F}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠕')], // ,.o (대문자 오미크론) - '\u{03A0}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠏')], // ,.p (대문자 파이) - '\u{03A1}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠗')], // ,.r (대문자 로) - '\u{03A4}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠞')], // ,.t (대문자 타우) - '\u{03A5}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠥')], // ,.u (대문자 입실론) - '\u{03A6}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠋')], // ,.f (대문자 피) - '\u{03A7}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠯')], // ,.& (대문자 키) - '\u{03A8}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠽')], // ,.y (대문자 프시) - '\u{03A9}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠺')], // ,.w (대문자 오메가) - '\u{0394}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠙')], // ,.d (대문자 델타) - '\u{2196}' => &[decode_unicode('⠪'), decode_unicode('⠢')], // [5 (왼쪽 위 화살표) - '\u{2197}' => &[decode_unicode('⠔'), decode_unicode('⠕')], // 9o (오른쪽 위 화살표) - '\u{2198}' => &[decode_unicode('⠢'), decode_unicode('⠕')], // 5o (오른쪽 아래 화살표) - '\u{2199}' => &[decode_unicode('⠪'), decode_unicode('⠔')], // [9 (왼쪽 아래 화살표) - '\u{21CF}' => &[decode_unicode('⠨'), decode_unicode('⠒'), decode_unicode('⠒'), decode_unicode('⠕')], // .33o (함의 부정) - '\u{2135}' => &[decode_unicode('⠗'), decode_unicode('⠋')], // rf (알레프) - '\u{2206}' => &[decode_unicode('⠸'), decode_unicode('⠬')], // _+ (세모꼴) - '\u{2219}' => &[decode_unicode('⠸'), decode_unicode('⠲')], // _4 (검정 동그라미) - '\u{FF03}' => &[decode_unicode('⠸'), decode_unicode('⠹')], // _? (샤프 기호) - '\u{1D9C}' => &[decode_unicode('⠘'), decode_unicode('⠉')], // ^c (여집합) - '\u{0302}' => &[decode_unicode('⠈'), decode_unicode('⠈'), decode_unicode('⠢')], // @@5 (결합 hat) - '\u{0304}' => &[decode_unicode('⠈'), decode_unicode('⠉')], // @c (결합 가로바) - '\u{0305}' => &[decode_unicode('⠈'), decode_unicode('⠉')], // @c (결합 윗줄) - '\u{2016}' => &[decode_unicode('⠳'), decode_unicode('⠳')], // \\ (이중 세로선) - '\u{2322}' => &[decode_unicode('⠈'), decode_unicode('⠪')], // @[ (호) - // PDF 수학 제65항 5 — 문자 위 결합 부호 (틸데) - '\u{0303}' => &[decode_unicode('⠈'), decode_unicode('⠈'), decode_unicode('⠔')], // @@9 (결합 틸데) - // 결합 윗 한 점 U+0307은 컨텍스트에 따라 의미가 다르다: - // - 숫자 뒤 : 순환소수 마크 (PDF 수학 제9항) → ⠈ - // - 문자 뒤 : 문자 위 한 점 (PDF 수학 제65항 5) → ⠈⠲ - // 이 SHORTCUT_MAP의 값은 숫자 뒤 기본형이고, 문자 뒤 처리는 rule_65에서 별도 분기한다. - '\u{0307}' => &[decode_unicode('⠈')], // @ (결합 윗점 - 기본/숫자 뒤) - '\u{0308}' => &[decode_unicode('⠈'), decode_unicode('⠲'), decode_unicode('⠲')], // @44 (결합 윗 두 점) - '\u{0309}' => &[decode_unicode('⠈'), decode_unicode('⠈'), decode_unicode('⠔')], // @@9 (결합 고리/훅) - '\u{030A}' => &[decode_unicode('⠈'), decode_unicode('⠈'), decode_unicode('⠔')], // @@9 (결합 윗고리) - '\u{211B}' => &[decode_unicode('⠠'), decode_unicode('⠗')], // ,R (ℛ = script R) - '~' => &[decode_unicode('⠈'), decode_unicode('⠔')], // @9 (물결 = 닮음) - '\u{0338}' => &[decode_unicode('⠨')], // . (부정 표지) - '\u{203E}' => &[decode_unicode('⠈'), decode_unicode('⠉')], // @c (선분 기호 U+203E) - '\u{20E1}' => &[decode_unicode('⠪'), decode_unicode('⠒'), decode_unicode('⠕')], // [3O (직선 기호 U+20E1) - '\u{20D7}' => &[decode_unicode('⠒'), decode_unicode('⠕')], // 3O (반직선 기호 U+20D7) - // PDF 수학 제60항 6 — 추론 기호 ⊢/⊣/⊨/⫤ - '\u{22A2}' => &[decode_unicode('⠸'), decode_unicode('⠒')], // _3 (⊢ vdash) - '\u{22A3}' => &[decode_unicode('⠈'), decode_unicode('⠸'), decode_unicode('⠒')], // @_3 (⊣ dashv) - '\u{22A8}' => &[decode_unicode('⠘'), decode_unicode('⠸'), decode_unicode('⠒')], // ^_3 (⊨ models) - '\u{2AE4}' => &[decode_unicode('⠨'), decode_unicode('⠸'), decode_unicode('⠒')], // ._3 (⫤ Dashv) - // PDF 수학 제60항 7 — 앞선다 ≲ (보다같거나 작다 + 닮음) - '\u{2272}' => &[decode_unicode('⠔'), decode_unicode('⠔'), decode_unicode('⠈'), decode_unicode('⠔')], // 99@9 (≲ lesssim) - // PDF 수학 제60항 8 — 앞서고같지않다 ≺ (보다작다) - '\u{227A}' => &[decode_unicode('⠔'), decode_unicode('⠔')], // 99 (≺ prec — same as <) - // PDF 수학 제61항 7 — 동치명제 ⇌ - '\u{21CC}' => &[decode_unicode('⠪'), decode_unicode('⠶'), decode_unicode('⠕')], // [7o (⇌ rightleftharpoons) - // PDF 수학 제23항 1 — 켤레복소수/평균값 macron ¯ - '\u{00AF}' => &[decode_unicode('⠈'), decode_unicode('⠉')], // @c (¯ macron) - // PDF 수학 제25항 — 총합 기호 ∑ (Greek capital Sigma과 동일 점형) - '\u{2211}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠎')], // ,.s - // PDF 수학 제26항 — 곱 기호 ∏ - '\u{220F}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠏')], // ,.p +#[derive(Debug, Clone, Copy)] +pub(crate) struct MathSymbolShortcut { + pub(crate) cells: &'static [u8], + pub(crate) fallback_meta: &'static RuleMeta, +} + +macro_rules! math_meta { + ($(($constant:ident, $section:literal, $name:literal, $description:literal)),+ $(,)?) => { + $( + pub(crate) static $constant: RuleMeta = RuleMeta { + section: $section, + subsection: None, + name: $name, + standard_ref: concat!("2024 Korean Braille Standard, 수학 제", $section, "항"), + description: $description, + }; + )+ + }; +} + +math_meta! { + (META_2, "2", "math_arithmetic_operator", "Arithmetic operators"), + (META_3, "3", "math_equality_symbol", "Equality symbols"), + (META_4, "4", "math_comparison_symbol", "Comparison symbols"), + (META_5, "5", "math_ratio_symbol", "Ratio and proportion symbols"), + (META_7, "7", "math_fraction_symbol", "Fraction notation"), + (META_9, "9", "math_repeating_decimal", "Repeating decimal marks"), + (META_10, "10", "math_arrow_symbol", "Arrow symbols"), + (META_13, "13", "math_greek_symbol", "Greek letters"), + (META_15, "15", "math_custom_binary_operator", "Custom binary operators"), + (META_16, "16", "math_base_subscript", "Base-notation subscripts"), + (META_17, "17", "math_prime_mark", "Prime marks"), + (META_18, "18", "math_superscript_symbol", "Superscript symbols"), + (META_19, "19", "math_subscript_symbol", "Subscript symbols"), + (META_21, "21", "math_absolute_value", "Absolute-value bars"), + (META_22, "22", "math_root_symbol", "Root symbols"), + (META_23, "23", "math_overline_symbol", "Overline and underline marks"), + (META_24, "24", "math_sequence_brace", "Sequence braces"), + (META_25, "25", "math_sigma_symbol", "Summation symbols"), + (META_27, "27", "math_divisibility_symbol", "Divisibility symbols"), + (META_28, "28", "math_norm_symbol", "Norm symbols"), + (META_30, "30", "math_dot_congruence", "Dot-congruence symbols"), + (META_31, "31", "math_asymptotic_equality", "Asymptotic equality"), + (META_32, "32", "math_congruence_symbol", "Congruence symbols"), + (META_33, "33", "math_geometric_operator", "Geometric operators"), + (META_34, "34", "math_negation_combiner", "Negation combining mark"), + (META_36, "36", "math_segment_symbol", "Segment and arc symbols"), + (META_37, "37", "math_line_symbol", "Bidirectional line symbols"), + (META_38, "38", "math_ray_symbol", "Right-arrow ray symbols"), + (META_39, "39", "math_angle_symbol", "Angle and ray symbols"), + (META_40, "40", "math_geometric_shape", "Geometric shapes"), + (META_41, "41", "math_perpendicular_symbol", "Perpendicular symbols"), + (META_42, "42", "math_similarity_symbol", "Similarity symbols"), + (META_43, "43", "math_identity_symbol", "Identity symbols"), + (META_44, "44", "math_parallel_symbol", "Parallel symbols"), + (META_50, "50", "math_infinity_symbol", "Infinity"), + (META_53, "53", "math_derivative_product", "Product signs in derivative formulas"), + (META_54, "54", "math_partial_derivative", "Partial derivatives"), + (META_55, "55", "math_nabla_symbol", "Nabla"), + (META_56, "56", "math_integral_symbol", "Indefinite integrals"), + (META_58, "58", "math_double_integral", "Double integrals"), + (META_59, "59", "math_contour_integral", "Contour integrals"), + (META_60, "60", "math_set_symbol", "Set and inference symbols"), + (META_61, "61", "math_logic_symbol", "Logic symbols"), + (META_64, "64", "math_hat_symbol", "Hat notation"), + (META_65, "65", "math_miscellaneous_symbol", "Miscellaneous math symbols"), +} + +pub(crate) static META_KOREAN_49: RuleMeta = RuleMeta { + section: "49", + subsection: None, + name: "korean_sentence_punctuation_in_math", + standard_ref: "2024 Korean Braille Standard, 한글 제49항", + description: "Question and exclamation marks inside math input", +}; +pub(crate) static META_KOREAN_50: RuleMeta = RuleMeta { + section: "50", + subsection: None, + name: "korean_middle_dot_in_math", + standard_ref: "2024 Korean Braille Standard, 한글 제50항", + description: "Middle dot inside math input", +}; +pub(crate) static META_KOREAN_51: RuleMeta = RuleMeta { + section: "51", + subsection: None, + name: "korean_colon_in_math", + standard_ref: "2024 Korean Braille Standard, 한글 제51항", + description: "Colon inside math input", +}; +pub(crate) static META_KOREAN_53: RuleMeta = RuleMeta { + section: "53", + subsection: None, + name: "korean_ellipsis_in_math", + standard_ref: "2024 Korean Braille Standard, 한글 제53항", + description: "Ellipsis inside math input", +}; +pub(crate) static META_KOREAN_59: RuleMeta = RuleMeta { + section: "59", + subsection: None, + name: "korean_semicolon_in_math", + standard_ref: "2024 Korean Braille Standard, 한글 제59항", + description: "Semicolon inside math input", +}; +pub(crate) static META_KOREAN_64: RuleMeta = RuleMeta { + section: "64", + subsection: None, + name: "korean_enclosed_number_in_math", + standard_ref: "2024 Korean Braille Standard, 한글 제64항", + description: "Circled numbers inside math input", +}; +pub(crate) static META_KOREAN_69_APPENDIX_2: RuleMeta = RuleMeta { + section: "69", + subsection: Some("붙임 2"), + name: "korean_degree_symbol_in_math", + standard_ref: "2024 Korean Braille Standard, 한글 제69항 [붙임 2]", + description: "Degree sign inside math input", +}; + +pub(crate) static MATH_SYMBOL_VARIANT_METAS: &[&RuleMeta] = &[ + &META_2, + &META_4, + &META_5, + &META_7, + &META_9, + &META_10, + &META_13, + &META_15, + &META_16, + &META_17, + &META_18, + &META_19, + &META_21, + &META_22, + &META_23, + &META_24, + &META_25, + &META_27, + &META_28, + &META_30, + &META_31, + &META_32, + &META_33, + &META_34, + &META_36, + &META_37, + &META_38, + &META_39, + &META_40, + &META_41, + &META_42, + &META_43, + &META_44, + &META_50, + &META_53, + &META_54, + &META_55, + &META_56, + &META_58, + &META_59, + &META_60, + &META_61, + &META_64, + &META_65, + &META_KOREAN_50, + &META_KOREAN_53, + &META_KOREAN_64, + &META_KOREAN_69_APPENDIX_2, + &UNDECLARED_MATH_RULE, +]; + +macro_rules! shortcut_map { + ($($meta:expr => { $($symbol:expr => $cells:expr),+ $(,)? }),+ $(,)?) => { + phf_map! { + $($( + $symbol => MathSymbolShortcut { + cells: $cells, + fallback_meta: $meta, + }, + )+)+ + } + }; +} + +static SHORTCUT_MAP: phf::Map = shortcut_map! { + &META_KOREAN_64 => { + '\u{2460}' => &[decode_unicode('⠼'), decode_unicode('⠂')], + '\u{2461}' => &[decode_unicode('⠼'), decode_unicode('⠆')], + '\u{2462}' => &[decode_unicode('⠼'), decode_unicode('⠒')], + '\u{2463}' => &[decode_unicode('⠼'), decode_unicode('⠲')], + '\u{2464}' => &[decode_unicode('⠼'), decode_unicode('⠢')], + '\u{2465}' => &[decode_unicode('⠼'), decode_unicode('⠖')], + '\u{2466}' => &[decode_unicode('⠼'), decode_unicode('⠶')], + '\u{2467}' => &[decode_unicode('⠼'), decode_unicode('⠦')], + '\u{2468}' => &[decode_unicode('⠼'), decode_unicode('⠔')], + '\u{2469}' => &[decode_unicode('⠼'), decode_unicode('⠴')], + }, + &META_2 => { + '+' => &[decode_unicode('⠢')], + '\u{2212}' => &[decode_unicode('⠔')], + '\u{00D7}' => &[decode_unicode('⠡')], + '\u{00F7}' => &[decode_unicode('⠌'), decode_unicode('⠌')], + '\u{00B1}' => &[decode_unicode('⠢'), decode_unicode('⠔')], + }, + &META_7 => { + '/' => &[decode_unicode('⠸'), decode_unicode('⠌')], + '\u{2500}' => &[decode_unicode('⠌')], + }, + &META_3 => { + '=' => &[decode_unicode('⠒'), decode_unicode('⠒')], + '\u{2260}' => &[decode_unicode('⠨'), decode_unicode('⠒'), decode_unicode('⠒')], + '\u{2252}' => &[decode_unicode('⠐'), decode_unicode('⠒'), decode_unicode('⠒')], + '\u{2248}' => &[decode_unicode('⠈'), decode_unicode('⠔'), decode_unicode('⠈'), decode_unicode('⠔')], + }, + &META_4 => { + '>' => &[decode_unicode('⠢'), decode_unicode('⠢')], + '<' => &[decode_unicode('⠔'), decode_unicode('⠔')], + '\u{2265}' => &[decode_unicode('⠲'), decode_unicode('⠲')], + '\u{2267}' => &[decode_unicode('⠲'), decode_unicode('⠲')], + '\u{2264}' => &[decode_unicode('⠖'), decode_unicode('⠖')], + '\u{2266}' => &[decode_unicode('⠖'), decode_unicode('⠖')], + '\u{226E}' => &[decode_unicode('⠨'), decode_unicode('⠔'), decode_unicode('⠔')], + '\u{226F}' => &[decode_unicode('⠨'), decode_unicode('⠢'), decode_unicode('⠢')], + '\u{2270}' => &[decode_unicode('⠨'), decode_unicode('⠖'), decode_unicode('⠖')], + '\u{2271}' => &[decode_unicode('⠨'), decode_unicode('⠲'), decode_unicode('⠲')], + }, + &META_5 => { + '\u{2236}' => &[decode_unicode('⠐'), decode_unicode('⠂')], + }, + &META_38 => { + '\u{2192}' => &[decode_unicode('⠒'), decode_unicode('⠕')], + '\u{20E1}' => &[decode_unicode('⠪'), decode_unicode('⠒'), decode_unicode('⠕')], + }, + &META_37 => { + '\u{2194}' => &[decode_unicode('⠪'), decode_unicode('⠒'), decode_unicode('⠕')], + }, + &META_10 => { + '\u{2190}' => &[decode_unicode('⠪'), decode_unicode('⠒')], + '\u{2191}' => &[decode_unicode('⠰'), decode_unicode('⠒'), decode_unicode('⠕')], + '\u{2193}' => &[decode_unicode('⠘'), decode_unicode('⠒'), decode_unicode('⠕')], + '\u{21D2}' => &[decode_unicode('⠒'), decode_unicode('⠒'), decode_unicode('⠕')], + '\u{21D4}' => &[decode_unicode('⠪'), decode_unicode('⠒'), decode_unicode('⠒'), decode_unicode('⠕')], + '\u{2196}' => &[decode_unicode('⠪'), decode_unicode('⠢')], + '\u{2197}' => &[decode_unicode('⠔'), decode_unicode('⠕')], + '\u{2198}' => &[decode_unicode('⠢'), decode_unicode('⠕')], + '\u{2199}' => &[decode_unicode('⠪'), decode_unicode('⠔')], + }, + &META_61 => { + '\u{21C4}' => &[decode_unicode('⠪'), decode_unicode('⠶'), decode_unicode('⠕')], + '\u{21CC}' => &[decode_unicode('⠪'), decode_unicode('⠶'), decode_unicode('⠕')], + '\u{00AC}' => &[decode_unicode('⠈'), decode_unicode('⠔')], + '\u{2200}' => &[decode_unicode('⠨'), decode_unicode('⠄')], + '\u{2203}' => &[decode_unicode('⠨'), decode_unicode('⠢')], + '\u{2204}' => &[decode_unicode('⠨'), decode_unicode('⠨'), decode_unicode('⠢')], + '\u{2227}' => &[decode_unicode('⠹')], + '\u{2228}' => &[decode_unicode('⠼')], + '\u{22BB}' => &[decode_unicode('⠼'), decode_unicode('⠤')], + '~' => &[decode_unicode('⠈'), decode_unicode('⠔')], + }, + &META_17 => { + '\u{2032}' => &[decode_unicode('⠤')], + '\u{2033}' => &[decode_unicode('⠤'), decode_unicode('⠤')], + '\u{2034}' => &[decode_unicode('⠤'), decode_unicode('⠤'), decode_unicode('⠤')], + }, + &META_18 => { + '\u{00B2}' => &[decode_unicode('⠘'), decode_unicode('⠼'), decode_unicode('⠃')], + '\u{00B3}' => &[decode_unicode('⠘'), decode_unicode('⠼'), decode_unicode('⠉')], + '\u{2074}' => &[decode_unicode('⠘'), decode_unicode('⠼'), decode_unicode('⠙')], + '\u{2075}' => &[decode_unicode('⠘'), decode_unicode('⠼'), decode_unicode('⠑')], + '\u{2077}' => &[decode_unicode('⠘'), decode_unicode('⠼'), decode_unicode('⠛')], + '\u{2079}' => &[decode_unicode('⠘'), decode_unicode('⠼'), decode_unicode('⠊')], + '\u{00B9}' => &[decode_unicode('⠘'), decode_unicode('⠼'), decode_unicode('⠁')], + '\u{2070}' => &[decode_unicode('⠘'), decode_unicode('⠼'), decode_unicode('⠚')], + '\u{1D4F}' => &[decode_unicode('⠘'), decode_unicode('⠅')], + '\u{1D50}' => &[decode_unicode('⠘'), decode_unicode('⠍')], + '\u{02E3}' => &[decode_unicode('⠘'), decode_unicode('⠭')], + '\u{207D}' => &[decode_unicode('⠘'), decode_unicode('⠦')], + '\u{207E}' => &[decode_unicode('⠴')], + '\u{207F}' => &[decode_unicode('⠘'), decode_unicode('⠝')], + '\u{207B}' => &[decode_unicode('⠘'), decode_unicode('⠔')], + '\u{207A}' => &[decode_unicode('⠘'), decode_unicode('⠢')], + }, + &META_16 => { + '\u{2080}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠚')], + '\u{2081}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠁')], + '\u{2082}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠃')], + '\u{2083}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠉')], + '\u{2084}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠙')], + '\u{2085}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠑')], + '\u{2086}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠋')], + '\u{2087}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠛')], + '\u{2088}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠓')], + '\u{2089}' => &[decode_unicode('⠰'), decode_unicode('⠼'), decode_unicode('⠊')], + '\u{208D}' => &[decode_unicode('⠰'), decode_unicode('⠦')], + '\u{208E}' => &[decode_unicode('⠴')], + }, + &META_19 => { + '\u{2090}' => &[decode_unicode('⠰'), decode_unicode('⠁')], + '\u{2098}' => &[decode_unicode('⠰'), decode_unicode('⠍')], + '\u{2093}' => &[decode_unicode('⠰'), decode_unicode('⠭')], + '\u{2099}' => &[decode_unicode('⠰'), decode_unicode('⠝')], + '\u{208A}' => &[decode_unicode('⠰'), decode_unicode('⠢')], + }, + &UNDECLARED_MATH_RULE => { + '\u{2044}' => &[decode_unicode('⠌')], + '\u{2E29}' => &[decode_unicode('⠄')], + '\u{2241}' => &[decode_unicode('⠨'), decode_unicode('⠈'), decode_unicode('⠔')], + '\u{21CF}' => &[decode_unicode('⠨'), decode_unicode('⠒'), decode_unicode('⠒'), decode_unicode('⠕')], + '\u{1D9C}' => &[decode_unicode('⠘'), decode_unicode('⠉')], + '\u{211B}' => &[decode_unicode('⠠'), decode_unicode('⠗')], + '\u{220F}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠏')], + }, + &META_34 => { + '\u{0338}' => &[decode_unicode('⠨')], + }, + &META_23 => { + '_' => &[decode_unicode('⠠'), decode_unicode('⠤')], + '\u{0332}' => &[decode_unicode('⠠'), decode_unicode('⠤')], + '\u{0304}' => &[decode_unicode('⠈'), decode_unicode('⠉')], + '\u{0305}' => &[decode_unicode('⠈'), decode_unicode('⠉')], + '\u{00AF}' => &[decode_unicode('⠈'), decode_unicode('⠉')], + }, + &META_21 => { + '|' => &[decode_unicode('⠳')], + }, + &META_KOREAN_69_APPENDIX_2 => { + '\u{00B0}' => &[decode_unicode('⠴'), decode_unicode('⠙')], + }, + &META_KOREAN_50 => { + '\u{00B7}' => &[decode_unicode('⠐')], + }, + &META_KOREAN_53 => { + '…' => &[decode_unicode('⠠'), decode_unicode('⠠'), decode_unicode('⠠')], + '⋯' => &[decode_unicode('⠠'), decode_unicode('⠠'), decode_unicode('⠠')], + }, + &META_22 => { + '\u{221A}' => &[decode_unicode('⠜')], + }, + &META_27 => { + '\u{2224}' => &[decode_unicode('⠨'), decode_unicode('⠳')], + }, + &META_39 => { + '\u{2220}' => &[decode_unicode('⠹')], + '\u{20D7}' => &[decode_unicode('⠒'), decode_unicode('⠕')], + }, + &META_41 => { + '\u{22A5}' => &[decode_unicode('⠴'), decode_unicode('⠄')], + }, + &META_44 => { + '\u{2225}' => &[decode_unicode('⠰'), decode_unicode('⠆')], + '\u{2AFD}' => &[decode_unicode('⠰'), decode_unicode('⠆')], + }, + &META_42 => { + '\u{223D}' => &[decode_unicode('⠠'), decode_unicode('⠄')], + }, + &META_43 => { + '\u{2261}' => &[decode_unicode('⠶'), decode_unicode('⠶')], + }, + &META_50 => { + '\u{221E}' => &[decode_unicode('⠿')], + }, + &META_56 => { + '\u{222B}' => &[decode_unicode('⠮')], + }, + &META_59 => { + '\u{222E}' => &[decode_unicode('⠾')], + }, + &META_58 => { + '\u{222C}' => &[decode_unicode('⠮'), decode_unicode('⠮')], + }, + &META_55 => { + '\u{2207}' => &[decode_unicode('⠸'), decode_unicode('⠩')], + }, + &META_54 => { + '\u{2202}' => &[decode_unicode('⠫')], + }, + &META_60 => { + '\u{2208}' => &[decode_unicode('⠖')], + '\u{220B}' => &[decode_unicode('⠲')], + '\u{2209}' => &[decode_unicode('⠨'), decode_unicode('⠖')], + '\u{220C}' => &[decode_unicode('⠨'), decode_unicode('⠲')], + '\u{2282}' => &[decode_unicode('⠖'), decode_unicode('⠂')], + '\u{2283}' => &[decode_unicode('⠐'), decode_unicode('⠲')], + '\u{2284}' => &[decode_unicode('⠨'), decode_unicode('⠖'), decode_unicode('⠂')], + '\u{2285}' => &[decode_unicode('⠨'), decode_unicode('⠐'), decode_unicode('⠲')], + '\u{2205}' => &[decode_unicode('⠨'), decode_unicode('⠋')], + '\u{222A}' => &[decode_unicode('⠬')], + '\u{2229}' => &[decode_unicode('⠩')], + '\u{22A2}' => &[decode_unicode('⠸'), decode_unicode('⠒')], + '\u{22A3}' => &[decode_unicode('⠈'), decode_unicode('⠸'), decode_unicode('⠒')], + '\u{22A8}' => &[decode_unicode('⠘'), decode_unicode('⠸'), decode_unicode('⠒')], + '\u{2AE4}' => &[decode_unicode('⠨'), decode_unicode('⠸'), decode_unicode('⠒')], + '\u{2272}' => &[decode_unicode('⠔'), decode_unicode('⠔'), decode_unicode('⠈'), decode_unicode('⠔')], + '\u{227A}' => &[decode_unicode('⠔'), decode_unicode('⠔')], + }, + &META_65 => { + '\u{2234}' => &[decode_unicode('⠠'), decode_unicode('⠡')], + '\u{2235}' => &[decode_unicode('⠈'), decode_unicode('⠌')], + '\u{2135}' => &[decode_unicode('⠗'), decode_unicode('⠋')], + '\u{FF03}' => &[decode_unicode('⠸'), decode_unicode('⠹')], + '\u{0303}' => &[decode_unicode('⠈'), decode_unicode('⠈'), decode_unicode('⠔')], + '\u{0308}' => &[decode_unicode('⠈'), decode_unicode('⠲'), decode_unicode('⠲')], + '\u{0309}' => &[decode_unicode('⠈'), decode_unicode('⠈'), decode_unicode('⠔')], + '\u{030A}' => &[decode_unicode('⠈'), decode_unicode('⠈'), decode_unicode('⠔')], + }, + &META_30 => { + '\u{224A}' => &[decode_unicode('⠈'), decode_unicode('⠔'), decode_unicode('⠈'), decode_unicode('⠔'), decode_unicode('⠒')], + }, + &META_31 => { + '\u{2243}' => &[decode_unicode('⠈'), decode_unicode('⠔'), decode_unicode('⠒')], + }, + &META_32 => { + '\u{2245}' => &[decode_unicode('⠈'), decode_unicode('⠔'), decode_unicode('⠒'), decode_unicode('⠒')], + }, + &META_33 => { + '\u{25B7}' => &[decode_unicode('⠸'), decode_unicode('⠜')], + '\u{25C1}' => &[decode_unicode('⠸'), decode_unicode('⠣')], + }, + &META_40 => { + '\u{25A1}' => &[decode_unicode('⠸'), decode_unicode('⠶')], + '\u{25B3}' => &[decode_unicode('⠸'), decode_unicode('⠬')], + '\u{25B1}' => &[decode_unicode('⠸'), decode_unicode('⠌'), decode_unicode('⠌')], + '\u{23E2}' => &[decode_unicode('⠸'), decode_unicode('⠌'), decode_unicode('⠡')], + '\u{2302}' => &[decode_unicode('⠸'), decode_unicode('⠪'), decode_unicode('⠅')], + '\u{2394}' => &[decode_unicode('⠸'), decode_unicode('⠪'), decode_unicode('⠕')], + '\u{29BE}' => &[decode_unicode('⠸'), decode_unicode('⠴'), decode_unicode('⠴')], + '\u{2206}' => &[decode_unicode('⠸'), decode_unicode('⠬')], + '\u{2219}' => &[decode_unicode('⠸'), decode_unicode('⠲')], + }, + &META_25 => { + '\u{2211}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠎')], + }, + &META_15 => { + '\u{2295}' => &[decode_unicode('⠸'), decode_unicode('⠢')], + '\u{2296}' => &[decode_unicode('⠸'), decode_unicode('⠔')], + '\u{2297}' => &[decode_unicode('⠸'), decode_unicode('⠡')], + '\u{2217}' => &[decode_unicode('⠸'), decode_unicode('⠣')], + '\u{2218}' => &[decode_unicode('⠸'), decode_unicode('⠴')], + }, + &META_13 => { + '\u{03B1}' => &[decode_unicode('⠨'), decode_unicode('⠁')], + '\u{03B2}' => &[decode_unicode('⠨'), decode_unicode('⠃')], + '\u{03B3}' => &[decode_unicode('⠨'), decode_unicode('⠛')], + '\u{03B4}' => &[decode_unicode('⠨'), decode_unicode('⠙')], + '\u{03B5}' => &[decode_unicode('⠨'), decode_unicode('⠑')], + '\u{03B6}' => &[decode_unicode('⠨'), decode_unicode('⠵')], + '\u{03B7}' => &[decode_unicode('⠨'), decode_unicode('⠱')], + '\u{03B8}' => &[decode_unicode('⠨'), decode_unicode('⠹')], + '\u{03B9}' => &[decode_unicode('⠨'), decode_unicode('⠊')], + '\u{03BA}' => &[decode_unicode('⠨'), decode_unicode('⠅')], + '\u{03BB}' => &[decode_unicode('⠨'), decode_unicode('⠇')], + '\u{03BC}' => &[decode_unicode('⠨'), decode_unicode('⠍')], + '\u{03BD}' => &[decode_unicode('⠨'), decode_unicode('⠝')], + '\u{03BE}' => &[decode_unicode('⠨'), decode_unicode('⠭')], + '\u{03BF}' => &[decode_unicode('⠨'), decode_unicode('⠕')], + '\u{03C0}' => &[decode_unicode('⠨'), decode_unicode('⠏')], + '\u{03C1}' => &[decode_unicode('⠨'), decode_unicode('⠗')], + '\u{03C3}' => &[decode_unicode('⠨'), decode_unicode('⠎')], + '\u{03C4}' => &[decode_unicode('⠨'), decode_unicode('⠞')], + '\u{03C5}' => &[decode_unicode('⠨'), decode_unicode('⠥')], + '\u{03C6}' => &[decode_unicode('⠨'), decode_unicode('⠋')], + '\u{03C7}' => &[decode_unicode('⠨'), decode_unicode('⠯')], + '\u{03C8}' => &[decode_unicode('⠨'), decode_unicode('⠽')], + '\u{03C9}' => &[decode_unicode('⠨'), decode_unicode('⠺')], + '\u{0391}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠁')], + '\u{0392}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠃')], + '\u{0393}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠛')], + '\u{0394}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠙')], + '\u{0395}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠑')], + '\u{0396}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠵')], + '\u{0397}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠱')], + '\u{0398}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠹')], + '\u{0399}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠊')], + '\u{039A}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠅')], + '\u{039B}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠇')], + '\u{039C}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠍')], + '\u{039D}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠝')], + '\u{039E}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠭')], + '\u{039F}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠕')], + '\u{03A0}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠏')], + '\u{03A1}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠗')], + '\u{03A3}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠎')], + '\u{03A4}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠞')], + '\u{03A5}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠥')], + '\u{03A6}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠋')], + '\u{03A7}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠯')], + '\u{03A8}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠽')], + '\u{03A9}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠺')], + }, + &META_36 => { + '\u{2322}' => &[decode_unicode('⠈'), decode_unicode('⠪')], + '\u{203E}' => &[decode_unicode('⠈'), decode_unicode('⠉')], + }, + &META_64 => { + '\u{0302}' => &[decode_unicode('⠈'), decode_unicode('⠈'), decode_unicode('⠢')], + }, + &META_28 => { + '\u{2016}' => &[decode_unicode('⠳'), decode_unicode('⠳')], + }, + &META_9 => { + '\u{0307}' => &[decode_unicode('⠈')], + }, }; pub fn encode_char_math_symbol_shortcut(text: char) -> Result<&'static [u8], String> { - if let Some(code) = SHORTCUT_MAP.get(&text) { - Ok(code) - } else { - Err("Invalid math symbol character".to_string()) - } + math_symbol_shortcut(text).map(|shortcut| shortcut.cells) +} + +pub(crate) fn math_symbol_shortcut(text: char) -> Result<&'static MathSymbolShortcut, String> { + SHORTCUT_MAP + .get(&text) + .ok_or_else(|| "Invalid math symbol character".to_string()) } pub fn is_math_symbol_char(text: char) -> bool { @@ -255,6 +525,37 @@ pub fn is_math_symbol_char(text: char) -> bool { mod test { use super::*; + const UNRESOLVED_SYMBOLS: &[char] = &['∏', '⇏', '≁', 'ᶜ', 'ℛ', '⁄', '⸩']; + + #[test] + fn every_resolved_shortcut_declares_a_real_fallback_article() { + let missing = SHORTCUT_MAP.entries().find(|(symbol, shortcut)| { + !UNRESOLVED_SYMBOLS.contains(symbol) && shortcut.fallback_meta.section == "?" + }); + + assert!( + missing.is_none(), + "resolved shortcut without article: {missing:?}" + ); + } + + #[rstest::rstest] + #[case::product('∏')] + #[case::not_implies('⇏')] + #[case::not_similar('≁')] + #[case::superscript_c('ᶜ')] + #[case::script_r('ℛ')] + #[case::fraction_slash('⁄')] + #[case::open_ended_delimiter('⸩')] + fn unresolved_shortcuts_keep_the_honest_placeholder(#[case] symbol: char) { + assert_eq!(SHORTCUT_MAP[&symbol].fallback_meta.section, "?"); + } + + #[test] + fn negation_overlay_uses_article_34() { + assert_eq!(SHORTCUT_MAP[&'\u{0338}'].fallback_meta.section, "34"); + } + /// `is_math_symbol_char` true 케이스 — 연산자/그리스/집합/미적분 기호 전체. #[rstest::rstest] // basic operators diff --git a/libs/braillify/src/rules/math/encoder.rs b/libs/braillify/src/rules/math/encoder.rs index 8d35ea5a..4473bdbf 100644 --- a/libs/braillify/src/rules/math/encoder.rs +++ b/libs/braillify/src/rules/math/encoder.rs @@ -14,6 +14,36 @@ use super::{ rule_54, rule_57, }; use crate::math_symbol_shortcut; +use crate::rules::RuleMeta; + +static DIGIT_SEPARATOR_META: RuleMeta = RuleMeta { + section: "41", + subsection: None, + name: "math_digit_separator", + standard_ref: "2024 Korean Braille Standard, 수학 제41항", + description: "Comma and grouping point between digits", +}; + +static SPACE_META: RuleMeta = RuleMeta { + section: "11", + subsection: None, + name: "math_expression_spacing", + standard_ref: "2024 Korean Braille Standard, 수학 제11항", + description: "Spacing around mathematical expressions", +}; + +static KOREAN_WORD_META: RuleMeta = RuleMeta { + section: "6", + subsection: None, + name: "math_korean_word", + standard_ref: "2024 Korean Braille Standard, 수학 제6항", + description: "Korean text and grouping brackets inside mathematics", +}; + +static RAW_TOKEN_VARIANT_METAS: &[&RuleMeta] = &[ + &math_symbol_shortcut::META_KOREAN_51, + &math_symbol_shortcut::META_KOREAN_59, +]; struct DigitSeparatorRule; @@ -21,13 +51,17 @@ pub(super) fn encode_generic_math_symbol( c: char, _is_direct_shortcut_symbol: bool, result: &mut Vec, -) -> Result<(), String> { - let encoded = math_symbol_shortcut::encode_char_math_symbol_shortcut(c)?; - result.extend_from_slice(encoded); - Ok(()) +) -> Result<&'static crate::rules::RuleMeta, String> { + let shortcut = math_symbol_shortcut::math_symbol_shortcut(c)?; + result.extend_from_slice(shortcut.cells); + Ok(shortcut.fallback_meta) } impl MathTokenRule for DigitSeparatorRule { + fn meta(&self) -> &'static RuleMeta { + &DIGIT_SEPARATOR_META + } + fn name(&self) -> &'static str { "DigitSeparatorRule" } @@ -113,6 +147,10 @@ fn should_suppress_space(tokens: &[MathToken], index: usize) -> bool { } impl MathTokenRule for SpaceRule { + fn meta(&self) -> &'static RuleMeta { + &SPACE_META + } + fn name(&self) -> &'static str { "SpaceRule" } @@ -259,6 +297,10 @@ fn should_suppress_after_operator(tokens: &[MathToken], index: usize) -> bool { } impl MathTokenRule for KoreanWordRule { + fn meta(&self) -> &'static RuleMeta { + &KOREAN_WORD_META + } + fn name(&self) -> &'static str { "KoreanWordRule" } @@ -302,6 +344,14 @@ use symbol_rule::MathSymbolRule; struct RawTokenRule; impl MathTokenRule for RawTokenRule { + fn meta(&self) -> &'static RuleMeta { + &math_symbol_shortcut::META_KOREAN_49 + } + + fn variant_metas(&self) -> &'static [&'static RuleMeta] { + RAW_TOKEN_VARIANT_METAS + } + fn name(&self) -> &'static str { "RawTokenRule" } @@ -325,13 +375,17 @@ impl MathTokenRule for RawTokenRule { let Some(MathToken::Raw(c)) = tokens.get(index) else { return Ok(MathTokenResult::Skip); }; - // PDF — 수학 컨텍스트 내 일반 구두점 중 PDF 65항 등에서 정의된 것만 처리한다. + // PDF 한글 제49·51·59항 — 수학 입력 안의 물음표·느낌표·쌍점·쌍반점. // 무차별 fallback은 다른 컨텍스트(예: 인용 부호)와 충돌하므로 명시적 매핑으로 한정. - if matches!(*c, ':' | ';' | '?' | '!') - && let Ok(encoded) = crate::symbol_shortcut::encode_char_symbol_shortcut(*c) - { + let meta = match *c { + '?' | '!' => &math_symbol_shortcut::META_KOREAN_49, + ':' => &math_symbol_shortcut::META_KOREAN_51, + ';' => &math_symbol_shortcut::META_KOREAN_59, + _ => return Err(format!("Unrecognized math character: '{}'", c)), + }; + if let Ok(encoded) = crate::symbol_shortcut::encode_char_symbol_shortcut(*c) { result.extend_from_slice(encoded); - return Ok(MathTokenResult::Consumed(1)); + return Ok(MathTokenResult::ConsumedWithMeta { tokens: 1, meta }); } Err(format!("Unrecognized math character: '{}'", c)) } @@ -749,6 +803,7 @@ mod tests { let rule = SpaceRule; assert_eq!(rule.name(), "SpaceRule"); assert_eq!(rule.priority(), 50); + assert_eq!(rule.meta().section, "11"); } /// DigitSeparatorRule metadata must remain stable. @@ -757,6 +812,7 @@ mod tests { let rule = DigitSeparatorRule; assert_eq!(rule.name(), "DigitSeparatorRule"); assert_eq!(rule.priority(), 50); + assert_eq!(rule.meta().section, "41"); let state = MathEncodeState::with_context(false, MathContext::default()); // matches returns true ONLY for DigitSeparator. let yes = vec![MathToken::DigitSeparator]; @@ -1037,6 +1093,7 @@ mod tests { let rule = KoreanWordRule; assert_eq!(rule.name(), "KoreanWordRule"); assert_eq!(rule.priority(), 50); + assert_eq!(rule.meta().section, "6"); let state = MathEncodeState::with_context(false, MathContext::default()); let yes = vec![kw("원")]; assert!(rule.matches(&yes, 0, &state)); @@ -1051,6 +1108,14 @@ mod tests { let rule = RawTokenRule; assert_eq!(rule.name(), "RawTokenRule"); assert_eq!(rule.priority(), 500); + assert_eq!(rule.meta().section, "49"); + assert_eq!( + rule.variant_metas() + .iter() + .map(|meta| meta.section) + .collect::>(), + vec!["51", "59"] + ); let state = MathEncodeState::with_context(false, MathContext::default()); let yes = vec![MathToken::Raw('?')]; assert!(rule.matches(&yes, 0, &state)); @@ -1205,6 +1270,44 @@ mod tests { }); } + #[rstest::rstest] + #[case::matrix(MathContext { + matrix_context_active: true, + math_mode_active: false, + })] + #[case::math_mode(MathContext { + matrix_context_active: false, + math_mode_active: true, + })] + #[case::matrix_math_mode(MathContext { + matrix_context_active: true, + math_mode_active: true, + })] + fn every_context_engine_exposes_the_same_flattened_registry(#[case] context: MathContext) { + let default_registry = math_engine_for_context(MathContext::default()).registry(); + let context_registry = math_engine_for_context(context).registry(); + + assert_eq!(context_registry.len(), default_registry.len()); + assert!( + context_registry + .iter() + .zip(default_registry) + .all(|(actual, expected)| std::ptr::eq(*actual, expected)) + ); + } + + #[test] + fn flattened_registry_has_one_explicit_unresolved_symbol_slot() { + let unresolved = math_rule_registry() + .into_iter() + .filter(|meta| { + std::ptr::eq(*meta, &super::super::math_token_rule::UNDECLARED_MATH_RULE) + }) + .count(); + + assert_eq!(unresolved, 1); + } + /// `KoreanWordRule.apply` defensive Skip when token is not KoreanWord. /// `matches()` guarantees correctness; the Skip arm is type-safety only. #[test] @@ -1285,6 +1388,32 @@ mod tests { assert!(result.is_empty()); } + #[rstest::rstest] + #[case::question('?', "49")] + #[case::exclamation('!', "49")] + #[case::colon(':', "51")] + #[case::semicolon(';', "59")] + fn raw_token_rule_reports_korean_punctuation_article( + #[case] symbol: char, + #[case] expected_section: &str, + ) { + let context = MathContext::default(); + let engine = MathTokenEngine::with_context(context); + let tokens = [MathToken::Raw(symbol)]; + let mut state = MathEncodeState::with_context(false, context); + let mut output = Vec::new(); + + let outcome = RawTokenRule + .apply(&tokens, 0, &mut output, &mut state, &engine) + .expect("supported punctuation should encode"); + + let MathTokenResult::ConsumedWithMeta { tokens, meta } = outcome else { + panic!("raw punctuation did not report selected metadata"); + }; + assert_eq!(tokens, 1); + assert_eq!(meta.section, expected_section); + } + /// encoder.rs line 348 — `encode_math_expression_with_context` Roman numeral fast-path /// when context is NON-default (forces the second is_roman_numeral check). #[test] diff --git a/libs/braillify/src/rules/math/encoder/symbol_rule.rs b/libs/braillify/src/rules/math/encoder/symbol_rule.rs index 15a738a6..d829f55a 100644 --- a/libs/braillify/src/rules/math/encoder/symbol_rule.rs +++ b/libs/braillify/src/rules/math/encoder/symbol_rule.rs @@ -53,6 +53,14 @@ impl MathTokenRule for MathSymbolRule { "MathSymbolRule" } + fn meta(&self) -> &'static crate::rules::RuleMeta { + &math_symbol_shortcut::META_3 + } + + fn variant_metas(&self) -> &'static [&'static crate::rules::RuleMeta] { + math_symbol_shortcut::MATH_SYMBOL_VARIANT_METAS + } + fn priority(&self) -> u16 { 100 } @@ -96,7 +104,10 @@ impl MathTokenRule for MathSymbolRule { } result.push(52); state.prev_was_number = false; - return Ok(MathTokenResult::Consumed(i - index)); + return Ok(MathTokenResult::ConsumedWithMeta { + tokens: i - index, + meta: &math_symbol_shortcut::META_65, + }); } // PDF 수학 제65항 1 — `#(UpperVar)` 패턴: 기수 표기. @@ -138,7 +149,10 @@ impl MathTokenRule for MathSymbolRule { result.push(52); // ⠴ (MathParen close) state.prev_was_number = false; let consumed = i + 1 - index; - return Ok(MathTokenResult::Consumed(consumed)); + return Ok(MathTokenResult::ConsumedWithMeta { + tokens: consumed, + meta: &math_symbol_shortcut::META_65, + }); } } } @@ -176,7 +190,10 @@ impl MathTokenRule for MathSymbolRule { } result.push(0); // PDF 제61항 ∀x/∃x 다음 한 칸 띄움 state.prev_was_number = false; - return Ok(MathTokenResult::Consumed(2)); + return Ok(MathTokenResult::ConsumedWithMeta { + tokens: 2, + meta: &math_symbol_shortcut::META_61, + }); } } @@ -218,7 +235,10 @@ impl MathTokenRule for MathSymbolRule { } state.prev_was_number = false; - return Ok(MathTokenResult::Consumed(close_idx + 1 - index)); + return Ok(MathTokenResult::ConsumedWithMeta { + tokens: close_idx + 1 - index, + meta: &math_symbol_shortcut::META_25, + }); } if *c == '\u{03A0}' && is_capital_pi_numeric_pair(tokens, index) { @@ -234,7 +254,10 @@ impl MathTokenRule for MathSymbolRule { } result.push(62); state.prev_was_number = false; - return Ok(MathTokenResult::Consumed(6)); + return Ok(MathTokenResult::ConsumedWithMeta { + tokens: 6, + meta: &math_symbol_shortcut::META_13, + }); } // In derivative/product formulas (제53항), middle dot is used as @@ -247,7 +270,10 @@ impl MathTokenRule for MathSymbolRule { { rule_2::encode_operator('\u{00D7}', tokens, index, result)?; state.prev_was_number = false; - return Ok(MathTokenResult::Consumed(1)); + return Ok(MathTokenResult::ConsumedWithMeta { + tokens: 1, + meta: &math_symbol_shortcut::META_53, + }); } let next_for_padding = Self::next_non_space(tokens, index + 1); @@ -295,24 +321,33 @@ impl MathTokenRule for MathSymbolRule { } } - if rule_3::is_equality_symbol(*c) { + let selected_meta: &'static crate::rules::RuleMeta = if rule_3::is_equality_symbol(*c) { rule_3::encode_equality_symbol(*c, result)?; + &math_symbol_shortcut::META_3 } else if rule_4::is_comparison_symbol(*c) { rule_4::encode_comparison_symbol(*c, result)?; + &math_symbol_shortcut::META_4 } else if rule_5::is_proportion_symbol(*c) { rule_5::encode_proportion_symbol(*c, result)?; + &math_symbol_shortcut::META_5 } else if rule_37::is_double_arrow_line_symbol(*c) { rule_37::encode_double_arrow_line_symbol(*c, result)?; + &math_symbol_shortcut::META_37 } else if rule_38::is_right_arrow_ray_symbol(*c) { rule_38::encode_right_arrow_ray_symbol(*c, result)?; + &math_symbol_shortcut::META_38 } else if rule_10::is_arrow_symbol(*c) { rule_10::encode_arrow_symbol(*c, result)?; + &math_symbol_shortcut::META_10 } else if rule_13::is_greek_symbol(*c) { rule_13::encode_greek_symbol(*c, result)?; + &math_symbol_shortcut::META_13 } else if rule_15::is_custom_binary_operator(*c) { rule_15::encode_custom_binary_operator(*c, result)?; + &math_symbol_shortcut::META_15 } else if rule_17::is_prime_mark(*c) { rule_17::encode_prime(*c, result)?; + &math_symbol_shortcut::META_17 // rule_20 (U+2252 ≒) and rule_29 (U+2248 ≈) dispatch arms were removed: // both chars are claimed by `rule_3::is_equality_symbol` earlier in the // chain, making rule_20/rule_29 arms structurally unreachable. @@ -327,15 +362,19 @@ impl MathTokenRule for MathSymbolRule { } else { rule_21::encode_absolute_value_close(result)?; } + &math_symbol_shortcut::META_21 } else if rule_23::is_overline_mark(*c) { rule_23::encode_overline(result)?; + &math_symbol_shortcut::META_23 } else if rule_24::is_sequence_brace(*c) { rule_24::encode_sequence_brace(*c, result)?; + &math_symbol_shortcut::META_24 } else if rule_27::is_divisibility_symbol(*c) { // `|` is always handled by rule_21::is_absolute_value_bar above; only // U+2224 (∤) reaches this arm. Probe-verified 2026-05-23. let encoded = math_symbol_shortcut::encode_char_math_symbol_shortcut(*c)?; result.extend_from_slice(encoded); + &math_symbol_shortcut::META_27 } else if rule_28::is_norm_symbol(*c) { if index == 0 { rule_28::encode_norm_open(result)?; @@ -344,30 +383,43 @@ impl MathTokenRule for MathSymbolRule { } else { rule_28::encode_norm_symbol(*c, result)?; } + &math_symbol_shortcut::META_28 } else if rule_30::is_dot_congruence(*c) { rule_30::encode_dot_congruence(*c, result)?; + &math_symbol_shortcut::META_30 } else if rule_31::is_asymptotic_equal(*c) { rule_31::encode_asymptotic_equal(*c, result)?; + &math_symbol_shortcut::META_31 } else if rule_32::is_congruence_symbol(*c) { rule_32::encode_congruence_symbol(*c, result)?; + &math_symbol_shortcut::META_32 } else if rule_33::is_geometric_operator(*c) { rule_33::encode_geometric_operator(*c, result)?; + &math_symbol_shortcut::META_33 } else if rule_36::is_arc_symbol(*c) { rule_36::encode_arc(*c, result)?; + &math_symbol_shortcut::META_36 } else if rule_39::is_angle_symbol(*c) { rule_39::encode_angle_symbol(*c, result)?; + &math_symbol_shortcut::META_39 } else if rule_40::is_geometric_shape(*c) { rule_40::encode_geometric_shape(*c, result)?; + &math_symbol_shortcut::META_40 } else if rule_41::is_perpendicular_symbol(*c) { rule_41::encode_perpendicular(*c, result)?; + &math_symbol_shortcut::META_41 } else if rule_42::is_similarity_symbol(*c) { rule_42::encode_similarity_symbol(*c, result)?; + &math_symbol_shortcut::META_42 } else if rule_43::is_identity_symbol(*c) { rule_43::encode_identity_symbol(*c, result)?; + &math_symbol_shortcut::META_43 } else if rule_44::is_parallel_symbol(*c) { rule_44::encode_parallel_symbol(*c, result)?; + &math_symbol_shortcut::META_44 } else if rule_50::is_special_constant(*c) { rule_50::encode_special_constant(*c, result)?; + &math_symbol_shortcut::META_50 } // 제52항 (Δ, U+0394) is captured by `rule_13::is_greek_symbol` earlier in // this dispatch chain, so an explicit rule_52 arm would be unreachable. @@ -375,16 +427,22 @@ impl MathTokenRule for MathSymbolRule { // callers that want delta encoding without going through MathSymbolRule. else if rule_54::is_partial_derivative(*c) { rule_54::encode_partial_derivative(*c, result)?; + &math_symbol_shortcut::META_54 } else if rule_55::is_nabla_symbol(*c) { rule_55::encode_nabla_symbol(*c, result)?; + &math_symbol_shortcut::META_55 } else if rule_56::is_integral_symbol(*c) { rule_56::encode_integral_symbol(*c, result)?; + &math_symbol_shortcut::META_56 } else if *c == '\u{222C}' { rule_58::encode_double_integral(*c, result)?; + &math_symbol_shortcut::META_58 } else if rule_59::is_contour_integral(*c) { rule_59::encode_contour_integral(*c, result)?; + &math_symbol_shortcut::META_59 } else if rule_65::is_therefore_because(*c) { rule_65::encode_therefore_because(*c, result)?; + &math_symbol_shortcut::META_65 } else if *c == '\u{0307}' && matches!( rule_12::prev_non_space(tokens, index), @@ -394,6 +452,7 @@ impl MathTokenRule for MathSymbolRule { // PDF 수학 제65항 5 — 문자 뒤 결합 윗 한 점 (ȧ 등). 숫자 뒤 순환소수와 구분. result.push(crate::unicode::decode_unicode('⠈')); result.push(crate::unicode::decode_unicode('⠲')); + &math_symbol_shortcut::META_65 } else { let is_direct_shortcut_symbol = rule_11::is_math_sentence_delimiter(*c) || rule_16::is_base_notation_subscript(*c) @@ -401,8 +460,8 @@ impl MathTokenRule for MathSymbolRule { || rule_60::is_set_symbol(*c) || rule_61::is_logic_symbol(*c) || rule_64::is_hat_notation(*c); - encode_generic_math_symbol(*c, is_direct_shortcut_symbol, result)?; - } + encode_generic_math_symbol(*c, is_direct_shortcut_symbol, result)? + }; if matches!(*c, '\u{2234}' | '\u{2235}') { let next_is_space = matches!(tokens.get(index + 1), Some(MathToken::Space)); @@ -434,7 +493,10 @@ impl MathTokenRule for MathSymbolRule { } state.prev_was_number = rule_9::is_repeating_decimal_mark(*c); - Ok(MathTokenResult::Consumed(1)) + Ok(MathTokenResult::ConsumedWithMeta { + tokens: 1, + meta: selected_meta, + }) } } @@ -466,6 +528,37 @@ mod tests { encode_math_expression_with_context(s, ctx).expect("math encode should succeed") } + #[rstest::rstest] + #[case::equality('=', "3")] + #[case::greek('α', "13")] + #[case::root('√', "22")] + #[case::set_membership('∈', "60")] + #[case::negation_overlay('\u{0338}', "34")] + #[case::unresolved_product('∏', "?")] + fn reports_the_selected_symbol_article(#[case] symbol: char, #[case] expected_section: &str) { + use super::super::super::encoder::math_engine_for_context; + use super::super::super::math_token_rule::{ + MathEncodeState, MathTokenResult, MathTokenRule, + }; + use super::super::super::parser::MathToken; + + let context = MathContext::default(); + let engine = math_engine_for_context(context); + let tokens = [MathToken::MathSymbol(symbol)]; + let mut output = Vec::new(); + let mut state = MathEncodeState::with_context(false, context); + + let outcome = super::MathSymbolRule + .apply(&tokens, 0, &mut output, &mut state, engine) + .expect("math symbol should encode"); + + let MathTokenResult::ConsumedWithMeta { tokens, meta } = outcome else { + panic!("math symbol did not report selected metadata"); + }; + assert_eq!(tokens, 1); + assert_eq!(meta.section, expected_section); + } + // ---------------- Specialised prefix arms ---------------- /// Math rule 61: a negation sign keeps its complete two-cell mapping @@ -1104,7 +1197,10 @@ mod tests { .apply(&tokens, 1, &mut result, &mut state, engine) .expect("operator should encode"); - assert!(matches!(action, MathTokenResult::Consumed(1))); + assert!(matches!( + action, + MathTokenResult::ConsumedWithMeta { tokens: 1, meta } if meta.section == "61" + )); assert_eq!(result.first().copied(), Some(0)); assert!(!state.prev_was_number); } diff --git a/libs/braillify/src/rules/math/math_token_rule.rs b/libs/braillify/src/rules/math/math_token_rule.rs index 2cec0f45..427f77d7 100644 --- a/libs/braillify/src/rules/math/math_token_rule.rs +++ b/libs/braillify/src/rules/math/math_token_rule.rs @@ -35,6 +35,11 @@ impl MathEncodeState { pub enum MathTokenResult { /// Rule consumed N tokens (advance index by N). Consumed(usize), + /// Rule consumed tokens under one of its declared metadata variants. + ConsumedWithMeta { + tokens: usize, + meta: &'static crate::rules::RuleMeta, + }, /// Rule did not apply. Try next rule. Skip, } @@ -63,6 +68,11 @@ pub trait MathTokenRule: Send + Sync { &UNDECLARED_MATH_RULE } + /// Additional articles this rule can select while dispatching variants. + fn variant_metas(&self) -> &'static [&'static crate::rules::RuleMeta] { + &[] + } + /// Priority (lower runs first). Default: 100. fn priority(&self) -> u16 { 100 @@ -107,7 +117,12 @@ impl MathTokenEngine { /// Metadata of every registered math rule, in [`RuleId`] order. pub(crate) fn registry(&self) -> Vec<&'static crate::rules::RuleMeta> { - self.rules.iter().map(|rule| rule.meta()).collect() + self.rules + .iter() + .flat_map(|rule| { + std::iter::once(rule.meta()).chain(rule.variant_metas().iter().copied()) + }) + .collect() } /// Encode a sequence of math tokens into braille bytes. @@ -135,25 +150,48 @@ impl MathTokenEngine { while i < tokens.len() { let mut handled = false; - for (rule_index, rule) in self.rules.iter().enumerate() { + let mut registry_base = 0usize; + for rule in &self.rules { let _ = rule.name(); - if rule.matches(tokens, i, &state) { - let start = result.len(); - let MathTokenResult::Consumed(n) = - rule.apply(tokens, i, result, &mut state, self)? - else { - continue; + let variant_metas = rule.variant_metas(); + let registry_len = 1 + variant_metas.len(); + if !rule.matches(tokens, i, &state) { + registry_base += registry_len; + continue; + } + + let start = result.len(); + let (consumed, local_offset) = + match rule.apply(tokens, i, result, &mut state, self)? { + MathTokenResult::Consumed(tokens) => (tokens, 0), + MathTokenResult::ConsumedWithMeta { tokens, meta } => { + let local_offset = std::iter::once(rule.meta()) + .chain(variant_metas.iter().copied()) + .position(|declared| std::ptr::eq(declared, meta)) + .ok_or_else(|| { + format!( + "{} reported undeclared metadata: {} {}", + rule.name(), + meta.section, + meta.name + ) + })?; + (tokens, local_offset) + } + MathTokenResult::Skip => { + registry_base += registry_len; + continue; + } }; - let rule_id = RuleId::math(rule_index); - attempt.push(rule_id, start - attempt_base, result.len() - start); - if let Some(sink) = trace.as_deref_mut() { - let token_index = sink.token_index() as usize; - sink.record_span(rule_id, token_index, start..result.len()); - } - i += n; - handled = true; - break; + let rule_id = RuleId::math(registry_base + local_offset); + attempt.push(rule_id, start - attempt_base, result.len() - start); + if let Some(sink) = trace.as_deref_mut() { + let token_index = sink.token_index() as usize; + sink.record_span(rule_id, token_index, start..result.len()); } + i += consumed; + handled = true; + break; } if !handled { return Err(format!( @@ -193,6 +231,140 @@ impl MathTokenEngine { #[cfg(test)] mod tests { use super::*; + use crate::rules::RuleMeta; + use crate::rules::trace::Trace; + + static PRIMARY_META: RuleMeta = RuleMeta { + section: "101", + subsection: None, + name: "test_primary", + standard_ref: "test primary", + description: "test primary", + }; + static SECONDARY_META: RuleMeta = RuleMeta { + section: "102", + subsection: None, + name: "test_secondary", + standard_ref: "test secondary", + description: "test secondary", + }; + static FOLLOWING_META: RuleMeta = RuleMeta { + section: "103", + subsection: None, + name: "test_following", + standard_ref: "test following", + description: "test following", + }; + static FOREIGN_META: RuleMeta = RuleMeta { + section: "104", + subsection: None, + name: "test_foreign", + standard_ref: "test foreign", + description: "test foreign", + }; + static VARIANT_METAS: [&RuleMeta; 1] = [&SECONDARY_META]; + + struct VariantRule; + + impl MathTokenRule for VariantRule { + fn name(&self) -> &'static str { + "VariantRule" + } + + fn meta(&self) -> &'static RuleMeta { + &PRIMARY_META + } + + fn variant_metas(&self) -> &'static [&'static RuleMeta] { + &VARIANT_METAS + } + + fn priority(&self) -> u16 { + 10 + } + + fn matches(&self, tokens: &[MathToken], index: usize, _state: &MathEncodeState) -> bool { + matches!(tokens.get(index), Some(MathToken::Variable(_))) + } + + fn apply( + &self, + _tokens: &[MathToken], + _index: usize, + result: &mut Vec, + _state: &mut MathEncodeState, + _engine: &MathTokenEngine, + ) -> Result { + result.push(1); + Ok(MathTokenResult::ConsumedWithMeta { + tokens: 1, + meta: &SECONDARY_META, + }) + } + } + + struct FollowingRule; + + impl MathTokenRule for FollowingRule { + fn name(&self) -> &'static str { + "FollowingRule" + } + + fn meta(&self) -> &'static RuleMeta { + &FOLLOWING_META + } + + fn priority(&self) -> u16 { + 20 + } + + fn matches(&self, tokens: &[MathToken], index: usize, _state: &MathEncodeState) -> bool { + matches!(tokens.get(index), Some(MathToken::Number(_))) + } + + fn apply( + &self, + _tokens: &[MathToken], + _index: usize, + result: &mut Vec, + _state: &mut MathEncodeState, + _engine: &MathTokenEngine, + ) -> Result { + result.push(2); + Ok(MathTokenResult::Consumed(1)) + } + } + + struct UndeclaredMetaRule; + + impl MathTokenRule for UndeclaredMetaRule { + fn name(&self) -> &'static str { + "UndeclaredMetaRule" + } + + fn meta(&self) -> &'static RuleMeta { + &PRIMARY_META + } + + fn matches(&self, tokens: &[MathToken], index: usize, _state: &MathEncodeState) -> bool { + matches!(tokens.get(index), Some(MathToken::Variable(_))) + } + + fn apply( + &self, + _tokens: &[MathToken], + _index: usize, + result: &mut Vec, + _state: &mut MathEncodeState, + _engine: &MathTokenEngine, + ) -> Result { + result.push(1); + Ok(MathTokenResult::ConsumedWithMeta { + tokens: 1, + meta: &FOREIGN_META, + }) + } + } /// `MathTokenRule::priority()` default implementation returns 100. /// Exercised by a dummy rule that doesn't override `priority()`. @@ -225,6 +397,73 @@ mod tests { } let r = DummyRule; assert_eq!(r.priority(), 100); + assert!(r.variant_metas().is_empty()); + } + + #[test] + fn registry_flattens_primary_then_variant_metadata_per_rule() { + let mut engine = MathTokenEngine::with_context(MathContext::default()); + engine.register(Box::new(FollowingRule)); + engine.register(Box::new(VariantRule)); + engine.finalize(); + + let registry = engine.registry(); + + assert_eq!(registry.len(), 3); + assert!(std::ptr::eq(registry[0], &PRIMARY_META)); + assert!(std::ptr::eq(registry[1], &SECONDARY_META)); + assert!(std::ptr::eq(registry[2], &FOLLOWING_META)); + } + + #[test] + fn traced_variant_uses_its_secondary_registry_slot() { + let mut engine = MathTokenEngine::with_context(MathContext::default()); + engine.register(Box::new(VariantRule)); + engine.finalize(); + let mut output = Vec::new(); + let mut trace = Trace::default(); + let mut sink = TraceSink::new(&mut trace); + + engine + .encode_tokens_traced(&[MathToken::Variable('x')], &mut output, Some(&mut sink)) + .unwrap(); + + assert_eq!(trace.events()[0].rule, RuleId::math(1)); + } + + #[test] + fn traced_rule_after_variant_rule_uses_flattened_registry_base() { + let mut engine = MathTokenEngine::with_context(MathContext::default()); + engine.register(Box::new(FollowingRule)); + engine.register(Box::new(VariantRule)); + engine.finalize(); + let mut output = Vec::new(); + let mut trace = Trace::default(); + let mut sink = TraceSink::new(&mut trace); + + engine + .encode_tokens_traced( + &[MathToken::Number("1".to_string())], + &mut output, + Some(&mut sink), + ) + .unwrap(); + + assert_eq!(trace.events()[0].rule, RuleId::math(2)); + } + + #[test] + fn encoded_variant_rejects_metadata_the_rule_never_declared() { + let mut engine = MathTokenEngine::with_context(MathContext::default()); + engine.register(Box::new(UndeclaredMetaRule)); + engine.finalize(); + let mut output = Vec::new(); + + let error = engine + .encode_tokens(&[MathToken::Variable('x')], &mut output) + .unwrap_err(); + + assert!(error.contains("UndeclaredMetaRule reported undeclared metadata")); } /// math_token_rule.rs line 97 - `MathTokenEngine.encode_tokens` returns Err From 39a84b30424a720cdc5e608addef8feadc8acd97 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 21:25:40 +0900 Subject: [PATCH 011/132] Find five symbols' articles by matching cells, not characters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven shortcut characters kept the honest placeholder because searching the standard's text for the character itself found nothing. That was the wrong search. The standard prints its examples in the internal braille notation, not in the Unicode characters an editor types, so a symbol is found by matching the cells it produces against the notation the article prints. Matched that way, five of the seven name their article plainly. The fraction slash writes ⠌, which 제7항 1 calls the 분수표 and prints as /. Script R and the not-similar sign write ⠠⠗ and ⠨⠈⠔, which 제34항 prints as ,R and .@9 for 관계가있다 and 관계가없다. The superscript c writes ⠘⠉, printed as ^c for 여집합 in 제60항 5. The not-implies sign writes ⠨⠒⠒⠕, printed as .33O for 항진명제의 부정 in 제61항 4. The same reading corrects 제34항's own description: it is the relation-symbol article, and the negation mark already filed under it belongs to its second clause, not to some separate rule about negation. Two symbols still have no article and now say why. ∏ carries the cells of Greek capital pi, which invites filing it under 제13항 by resemblance; the standard never mentions it, so it stays unattributed on purpose. ⸩ stands in for LaTeX's \right., a null delimiter with nothing printed for an article to govern. Only the article attached to each entry moved; all 221 character-to-cell mappings were compared pair by pair and are unchanged. Placeholders over 5,160 traced sentences fall from 11 to 3, all three now the null delimiter. --- libs/braillify/src/math_symbol_shortcut.rs | 43 +++++++++++++++------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/libs/braillify/src/math_symbol_shortcut.rs b/libs/braillify/src/math_symbol_shortcut.rs index c180abc9..176f507c 100644 --- a/libs/braillify/src/math_symbol_shortcut.rs +++ b/libs/braillify/src/math_symbol_shortcut.rs @@ -49,7 +49,7 @@ math_meta! { (META_31, "31", "math_asymptotic_equality", "Asymptotic equality"), (META_32, "32", "math_congruence_symbol", "Congruence symbols"), (META_33, "33", "math_geometric_operator", "Geometric operators"), - (META_34, "34", "math_negation_combiner", "Negation combining mark"), + (META_34, "34", "math_relation_symbol", "Relation symbols and their negations"), (META_36, "36", "math_segment_symbol", "Segment and arc symbols"), (META_37, "37", "math_line_symbol", "Bidirectional line symbols"), (META_38, "38", "math_ray_symbol", "Right-arrow ray symbols"), @@ -307,16 +307,22 @@ static SHORTCUT_MAP: phf::Map = shortcut_map! { '\u{208A}' => &[decode_unicode('⠰'), decode_unicode('⠢')], }, &UNDECLARED_MATH_RULE => { - '\u{2044}' => &[decode_unicode('⠌')], '\u{2E29}' => &[decode_unicode('⠄')], - '\u{2241}' => &[decode_unicode('⠨'), decode_unicode('⠈'), decode_unicode('⠔')], - '\u{21CF}' => &[decode_unicode('⠨'), decode_unicode('⠒'), decode_unicode('⠒'), decode_unicode('⠕')], - '\u{1D9C}' => &[decode_unicode('⠘'), decode_unicode('⠉')], - '\u{211B}' => &[decode_unicode('⠠'), decode_unicode('⠗')], '\u{220F}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠏')], }, &META_34 => { '\u{0338}' => &[decode_unicode('⠨')], + '\u{211B}' => &[decode_unicode('⠠'), decode_unicode('⠗')], + '\u{2241}' => &[decode_unicode('⠨'), decode_unicode('⠈'), decode_unicode('⠔')], + }, + &META_60 => { + '\u{1D9C}' => &[decode_unicode('⠘'), decode_unicode('⠉')], + }, + &META_61 => { + '\u{21CF}' => &[decode_unicode('⠨'), decode_unicode('⠒'), decode_unicode('⠒'), decode_unicode('⠕')], + }, + &META_7 => { + '\u{2044}' => &[decode_unicode('⠌')], }, &META_23 => { '_' => &[decode_unicode('⠠'), decode_unicode('⠤')], @@ -539,21 +545,30 @@ mod test { ); } + /// `∏` is written with the cells of Greek capital pi but the standard never + /// names it, and `⸩` stands in for LaTeX's `\right.` null delimiter, which + /// has no printed counterpart for an article to govern. Both keep the + /// placeholder rather than borrowing an article by resemblance. #[rstest::rstest] #[case::product('∏')] - #[case::not_implies('⇏')] - #[case::not_similar('≁')] - #[case::superscript_c('ᶜ')] - #[case::script_r('ℛ')] - #[case::fraction_slash('⁄')] #[case::open_ended_delimiter('⸩')] fn unresolved_shortcuts_keep_the_honest_placeholder(#[case] symbol: char) { assert_eq!(SHORTCUT_MAP[&symbol].fallback_meta.section, "?"); } - #[test] - fn negation_overlay_uses_article_34() { - assert_eq!(SHORTCUT_MAP[&'\u{0338}'].fallback_meta.section, "34"); + /// Each of these was identified by matching its cells against the notation + /// printed in the standard, not by searching for the character itself: + /// `⠌` is the 분수표 of 제7항 1, `⠠⠗`/`⠨⠈⠔` are 관계가있다/관계가없다 of + /// 제34항, `⠘⠉` is 여집합 of 제60항 5, `⠨⠒⠒⠕` is 항진명제의 부정 of 제61항 4. + #[rstest::rstest] + #[case::fraction_slash('⁄', "7")] + #[case::script_r('ℛ', "34")] + #[case::not_similar('≁', "34")] + #[case::negation_overlay('\u{0338}', "34")] + #[case::superscript_c('ᶜ', "60")] + #[case::not_implies('⇏', "61")] + fn cell_matched_shortcuts_name_their_article(#[case] symbol: char, #[case] section: &str) { + assert_eq!(SHORTCUT_MAP[&symbol].fallback_meta.section, section); } /// `is_math_symbol_char` true 케이스 — 연산자/그리스/집합/미적분 기호 전체. From f7d8240bd57e54957ee36d2f553f2dd5dacb4385 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 22:48:16 +0900 Subject: [PATCH 012/132] Claim the inline math that English prose carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A \$...\$ span standing alone is a formula and the math engine owns it. The same span inside English prose stays on the UEB side, where the parser turns it into a Technical token and rule_11 encodes it. The engine emitted those cells and told the tracer nothing, so a sentence like \bc \{D}\$\ explained its first four cells and left the other six belonging to no rule at all. Where a whole line was formula and prose together, nothing at all was explained. RUEB calls this code switching: 14.6.2 is the short inline fragment among ordinary text, which is exactly this token's shape. 14.6.3 covers the long terminal passage and is not what the parser produces here. The cells are recorded on their own channel rather than as a contraction attempt, because a word is credited to 4.1 only while the attempt count has not moved since it began; booking these as attempts would have stripped the attribution from neighbouring spelled-out words. Alignment places the direct spans first and then searches for word and indicator cells outside them, so no cell is claimed twice. Two smaller holes closed with it. The code-switch encoder may try a span, emit records, then refuse the input and hand it back; those records used to survive into a trace they no longer described, and are now rolled back to a checkpoint. And the symbol arms that finish through a shared continue never settled their pending output, which is why fullwidth = and + went unexplained. Attribution only: no output cell changes. Over 5,160 traced sentences the unexplained cells fall from 1,892 to 449 and fully explained sentences rise from 5,119 to 5,127. Fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. --- libs/braillify/src/encoder.rs | 25 ++- libs/braillify/src/lib.rs | 1 + .../braillify/src/rules/english_ueb/engine.rs | 46 ++++- libs/braillify/src/rules/english_ueb/mod.rs | 162 ++++++++++++++++-- 4 files changed, 212 insertions(+), 22 deletions(-) diff --git a/libs/braillify/src/encoder.rs b/libs/braillify/src/encoder.rs index 273a2f61..ddf93b1c 100644 --- a/libs/braillify/src/encoder.rs +++ b/libs/braillify/src/encoder.rs @@ -333,13 +333,28 @@ impl Encoder { // contains `-`, `(`, `,`, `.` is NOT blocked (that over-broad reading // of the math detector would swallow `child-ish-ly`, `with(er)`, …). && !crate::rules::english_ueb::is_math_owned(text) - && let Some(bytes) = crate::rules::english_ueb::try_encode(text) { - result.extend(bytes); - if let Some(sink) = trace.as_mut() { - sink.trace.set_path(TracePath::EnglishUeb); + let encoded = if trace.is_some() { + crate::rules::english_ueb::try_encode_traced(text) + } else { + crate::rules::english_ueb::try_encode(text).map(|cells| (cells, Vec::new())) + }; + if let Some((bytes, spans)) = encoded { + let output_base = result.len(); + result.extend(bytes); + if let Some(sink) = trace.as_mut() { + let token_index = sink.token_index() as usize; + for (rule, output) in spans { + sink.record_span( + rule, + token_index, + output_base + output.start as usize..output_base + output.end as usize, + ); + } + sink.trace.set_path(TracePath::EnglishUeb); + } + return Ok(()); } - return Ok(()); } self.encode_via_ir(text, result, trace) } diff --git a/libs/braillify/src/lib.rs b/libs/braillify/src/lib.rs index 861b8917..df8c47da 100644 --- a/libs/braillify/src/lib.rs +++ b/libs/braillify/src/lib.rs @@ -1722,6 +1722,7 @@ mod trace_tests { #[case::english("the child was here")] #[case::math("3+4=7")] #[case::chemical("C_{2}H_{4}(g) + H_{2}O(g) -> C_{2}H_{5}OH(g)")] + #[case::inline_chemical("$C_{2}H_{4}$(g)+$H_{2}O$(g)→$C_{2}H_{5}OH$(g)")] fn no_cell_is_claimed_twice(#[case] input: &str) { let (cells, trace) = encode_with_trace(input).expect("input must encode"); diff --git a/libs/braillify/src/rules/english_ueb/engine.rs b/libs/braillify/src/rules/english_ueb/engine.rs index 91f5773b..19d99ee0 100644 --- a/libs/braillify/src/rules/english_ueb/engine.rs +++ b/libs/braillify/src/rules/english_ueb/engine.rs @@ -61,6 +61,15 @@ pub(super) const SPACE: u8 = 0; type ForeignScope = Option<(super::rule_13::AccentCode, bool)>; type ActiveTypeformPassage = (usize, super::token::Typeform, bool, ForeignScope); +fn settle_inline_technical_attribution(start: Option<(usize, usize)>, out: &[u8]) { + if let Some((start, checkpoint)) = start + && out.len() > start + { + super::rollback_attributions(checkpoint); + super::record_direct(super::UebMoveSource::InlineNemethCode, &out[start..]); + } +} + /// Capitalisation pattern of a word (§8 subset currently supported). #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Caps { @@ -553,9 +562,20 @@ impl EnglishUebEngine { // checked right after its arm. Carrying the mark to the next iteration // (and past the loop) reaches every branch without touching any of them. let mut pending_word: Option<(usize, usize)> = None; + let mut pending_symbol = None; + let mut inline_technical_start = None; + let mut pending_inline_technical = None; + let mut suppress_next_inline_dollar = false; for i in 0..tokens.len() { + settle_inline_technical_attribution(pending_inline_technical.take(), &out); super::settle_word_attribution(pending_word.take(), &out); + super::settle_symbol_attribution(pending_symbol.take(), &out); + if matches!(tokens[i], EnglishToken::Technical(_)) { + let start = inline_technical_start.take(); + suppress_next_inline_dollar |= start.is_some(); + settle_inline_technical_attribution(start, &out); + } if let Some((end, form)) = nested_inner_passage && i >= end { @@ -633,6 +653,18 @@ impl EnglishUebEngine { if !spatial_grade1_passage && cap_start[i] { out.extend([CAPITAL, CAPITAL, CAPITAL]); } + if matches!(tokens[i], EnglishToken::Symbol(_)) { + pending_symbol = Some(out.len()); + } + if matches!(tokens[i], EnglishToken::Symbol('$')) { + if suppress_next_inline_dollar { + suppress_next_inline_dollar = false; + } else if let Some(start) = inline_technical_start.take() { + pending_inline_technical = Some(start); + } else { + inline_technical_start = Some((out.len(), super::attribution_checkpoint())); + } + } match &tokens[i] { EnglishToken::Space => { encode_space_arm!(tokens, out, prev_was_number, numeric_mode, skip_to, line_mode_active, preserve_spatial_newlines, flatten_line_layout, spatial_grade1_passage, poem_linear_context, collapse_prose_double_space, skip_flattened_line_indent, numeric_separator_count, i) @@ -657,7 +689,15 @@ impl EnglishUebEngine { } EnglishToken::Technical(chars) => { skip_flattened_line_indent = false; - out.extend(super::rule_11::encode_technical(chars)?); + let cells = super::rule_11::encode_technical(chars)?; + // Direct records do not change `attempt_count`, so the next + // spelled-out word can still settle to §4.1. They also mask + // any nested word attempts from this complete §14.6.2 span. + super::push_direct( + &mut out, + super::UebMoveSource::InlineNemethCode, + &cells, + ); prev_was_number = false; numeric_mode = false; } @@ -900,9 +940,7 @@ impl EnglishUebEngine { numeric_mode = true; } EnglishToken::Symbol(c) => { - let symbol_start = out.len(); encode_symbol_arm!(self, tokens, out, prev_was_number, numeric_mode, skip_to, line_mode_active, passage, cap_term, in_passage, url_listing, regex_listing, foreign_code, spanish_foreign, foreign_passage, early_english, preserve_spatial_newlines, skip_flattened_line_indent, numeric_separator_count, i, c); - super::record_whole_word(super::UebMoveSource::Symbol, &out[symbol_start..]); } EnglishToken::Styled(_, form) => { encode_styled_arm!(self, tokens, out, prev_was_number, numeric_mode, skip_to, passage, in_passage, foreign_code, spanish_foreign, foreign_passage, drop_styled_typeform_for_code_switch, skip_flattened_line_indent, nested_inner_passage, i, form) @@ -913,7 +951,9 @@ impl EnglishUebEngine { out.extend([CAPITAL, decode_unicode('⠄')]); } } + settle_inline_technical_attribution(pending_inline_technical.take(), &out); super::settle_word_attribution(pending_word.take(), &out); + super::settle_symbol_attribution(pending_symbol.take(), &out); if let Some(span) = grade1_passage && span.needs_terminator { diff --git a/libs/braillify/src/rules/english_ueb/mod.rs b/libs/braillify/src/rules/english_ueb/mod.rs index 918d9d57..7cbc27e5 100644 --- a/libs/braillify/src/rules/english_ueb/mod.rs +++ b/libs/braillify/src/rules/english_ueb/mod.rs @@ -66,7 +66,8 @@ thread_local! { enum AttributionRecord { Word(WordAttempt), - Indicator(IndicatorAttempt), + Indicator(NonWordAttempt), + Direct(NonWordAttempt), } /// The cells one attempt produced, plus where each rule's cells sat inside them. @@ -75,7 +76,7 @@ struct WordAttempt { moves: Vec<(crate::rules::trace::RuleId, u32, u32)>, } -struct IndicatorAttempt { +struct NonWordAttempt { cells: Vec, rule: crate::rules::trace::RuleId, } @@ -161,6 +162,18 @@ fn collect_selected( encoded.map(|cells| (cells, records)) } +fn attribution_checkpoint() -> usize { + ATTRIBUTIONS.with(|slot| slot.borrow().as_ref().map_or(0, Vec::len)) +} + +fn rollback_attributions(checkpoint: usize) { + ATTRIBUTIONS.with(|slot| { + if let Some(records) = slot.borrow_mut().as_mut() { + records.truncate(checkpoint); + } + }); +} + /// Place each attempt's moves in the finished output, skipping attempts the /// engine discarded. /// @@ -168,43 +181,62 @@ fn collect_selected( /// already placed. A discarded attempt is recognised by its cells not appearing /// there — the engine never emitted them. fn align_selected(cells: &[u8], records: &[AttributionRecord]) -> Vec { + let mut direct_spans = Vec::new(); + let mut direct_cursor = 0usize; + for record in records { + if let AttributionRecord::Direct(direct) = record + && let Some(base) = find_from(cells, &direct.cells, direct_cursor) + { + let end = base + direct.cells.len(); + direct_spans.push((direct.rule, base as u32..end as u32)); + direct_cursor = end; + } + } + let mut indicator_spans = Vec::new(); let mut indicator_cursor = 0usize; for record in records { match record { - AttributionRecord::Word(_) => {} + AttributionRecord::Word(_) | AttributionRecord::Direct(_) => {} AttributionRecord::Indicator(indicator) => { - if let Some(base) = find_from(cells, &indicator.cells, indicator_cursor) { + if let Some(base) = + find_from_outside(cells, &indicator.cells, indicator_cursor, &direct_spans) + { let end = base + indicator.cells.len(); - indicator_spans.push((indicator.rule, base as u32..end as u32)); + push_without_indicators( + &mut indicator_spans, + (indicator.rule, base as u32..end as u32), + &direct_spans, + ); indicator_cursor = end; } } } } + let mut fixed_spans = direct_spans.clone(); + fixed_spans.extend(indicator_spans.iter().cloned()); + fixed_spans.sort_by_key(|(_, range)| range.start); + let mut spans = Vec::new(); let mut cursor = 0usize; for record in records { let attempt = match record { AttributionRecord::Word(attempt) => attempt, - AttributionRecord::Indicator(_) => continue, + AttributionRecord::Indicator(_) | AttributionRecord::Direct(_) => continue, }; - let Some(base) = find_from(cells, &attempt.cells, cursor) else { + let Some(base) = find_from_outside(cells, &attempt.cells, cursor, &direct_spans) else { continue; }; for (rule, offset, len) in &attempt.moves { let start = base + *offset as usize; let end = start + *len as usize; - push_without_indicators( - &mut spans, - (*rule, start as u32..end as u32), - &indicator_spans, - ); + push_without_indicators(&mut spans, (*rule, start as u32..end as u32), &fixed_spans); } cursor = base + attempt.cells.len(); } spans.extend(indicator_spans); + spans.extend(direct_spans); // An empty cell between words is the inter-word blank, the same structural // output the Korean emitter accounts for. It carries no dots, so there is no // other thing it could be. @@ -247,6 +279,26 @@ fn find_from(haystack: &[u8], needle: &[u8], from: usize) -> Option { .map(|offset| offset + from) } +fn find_from_outside( + haystack: &[u8], + needle: &[u8], + from: usize, + excluded: &[UebSpan], +) -> Option { + let mut cursor = from; + loop { + let base = find_from(haystack, needle, cursor)?; + let end = base + needle.len(); + let overlap = excluded + .iter() + .find(|(_, range)| range.start < end as u32 && (base as u32) < range.end); + let Some((_, range)) = overlap else { + return Some(base); + }; + cursor = range.end as usize; + } +} + /// Sources of a selected contraction move that are not [`ContractionRule`] /// objects. They occupy the first slots of the UEB id space so a contraction /// rule's id stays a fixed offset from its registration index. @@ -265,10 +317,11 @@ pub(crate) enum UebMoveSource { Grade1Indicator = 8, CapitalLetterIndicator = 9, CapitalisedWordIndicator = 10, + InlineNemethCode = 11, } /// Number of non-rule slots reserved before the contraction rules. -pub(crate) const UEB_RESERVED_SLOTS: usize = 11; +pub(crate) const UEB_RESERVED_SLOTS: usize = 12; /// Record a whole word that a lookup table resolved in one step, bypassing the /// contraction search. Without this a wordsign or shortform would leave its @@ -305,6 +358,14 @@ pub(super) fn settle_word_attribution(pending: Option, out: &[u8]) } } +pub(super) fn settle_symbol_attribution(start: Option, out: &[u8]) { + if let Some(start) = start + && out.len() > start + { + record_whole_word(UebMoveSource::Symbol, &out[start..]); + } +} + pub(super) fn record_whole_word(source: UebMoveSource, cells: &[u8]) { let mut attempt = AttemptRecorder::new(); attempt.push( @@ -321,7 +382,25 @@ pub(super) fn push_indicator(out: &mut Vec, source: UebMoveSource, cells: &[ if let Ok(mut slot) = slot.try_borrow_mut() && let Some(records) = slot.as_mut() { - records.push(AttributionRecord::Indicator(IndicatorAttempt { + records.push(AttributionRecord::Indicator(NonWordAttempt { + cells: cells.to_vec(), + rule: crate::rules::trace::RuleId::ueb(source as usize), + })); + } + }); +} + +pub(super) fn push_direct(out: &mut Vec, source: UebMoveSource, cells: &[u8]) { + out.extend_from_slice(cells); + record_direct(source, cells); +} + +pub(super) fn record_direct(source: UebMoveSource, cells: &[u8]) { + ATTRIBUTIONS.with(|slot| { + if let Ok(mut slot) = slot.try_borrow_mut() + && let Some(records) = slot.as_mut() + { + records.push(AttributionRecord::Direct(NonWordAttempt { cells: cells.to_vec(), rule: crate::rules::trace::RuleId::ueb(source as usize), })); @@ -407,6 +486,13 @@ static UEB_NON_RULE_METAS: [crate::rules::RuleMeta; UEB_RESERVED_SLOTS] = [ standard_ref: "RUEB 2024 §8.4", description: "Capital indicators applying to the following word", }, + crate::rules::RuleMeta { + section: "14.6.2", + subsection: None, + name: "ueb_inline_nemeth_code", + standard_ref: "RUEB 2024 §14.6.2", + description: "Nemeth Code within UEB text", + }, ]; /// Metadata of every UEB move source, in [`crate::rules::trace::RuleId`] order: @@ -468,6 +554,7 @@ fn encode_english(text: &str, explicit_english: bool) -> Option> { // §14.3.1/14.3.2: non-UEB (Arabic/Greek/IPA/music) runs inside English prose // take the non-UEB word/passage indicators, with the surrounding English // encoded by the closure. Returns None when no code-switch span is present. + let attribution_checkpoint = attribution_checkpoint(); if let Some(cells) = rule_14::encode_with_code_switches(&composed, |segment| { let tokens = parser::parse_english(segment); if tokens.is_empty() { @@ -478,6 +565,7 @@ fn encode_english(text: &str, explicit_english: bool) -> Option> { }) { return Some(cells); } + rollback_attributions(attribution_checkpoint); let tokens = parser::parse_english(&composed); if tokens.is_empty() { return None; @@ -1093,6 +1181,52 @@ mod is_ueb_eligible_tests { mod encode_pipeline_tests { use super::encode_forced; + #[rstest::rstest] + #[case::two_assignments("$P_{D}$=1,000kN, $P_{L}$=600kN", 12)] + #[case::preceded_by_prose("abc $P_{D}$", 6)] + #[case::followed_by_prose("$I_{7}H^{T}$(mod 2)", 12)] + #[case::ethylene_equation("$C_{2}H_{4}$(g)+$H_{2}O$(g)→$C_{2}H_{5}OH$(g)", 36)] + #[case::carbon_monoxide_equation("$CO$(g)+$H_{2}O$(g)→$CO_{2}$(g)+$H_{2}$(g)", 27)] + #[case::capitalised_spelled_word_after_span("abc $P_{D}$ Cat", 6)] + fn inline_technical_cells_are_claimed_by_14_6_2( + #[case] input: &str, + #[case] expected_technical_cells: usize, + ) { + let (cells, trace) = + crate::encode_with_trace(input).expect("inline technical input must encode"); + let untraced = crate::encode(input).expect("inline technical input must encode untraced"); + let technical_cells: usize = trace + .events() + .iter() + .filter(|event| { + event + .rule + .meta() + .is_some_and(|meta| meta.section == "14.6.2") + }) + .map(|event| event.output.len()) + .sum(); + let mut claims = vec![0u8; cells.len()]; + for event in trace.events() { + for index in event.output.clone() { + claims[index as usize] += 1; + } + } + + assert_eq!(cells, untraced, "trace collection must not change output"); + assert!( + claims.iter().all(|count| *count == 1), + "claims={claims:?}, events={:?}", + trace.events() + ); + assert_eq!( + technical_cells, + expected_technical_cells, + "events={:?}", + trace.events() + ); + } + /// An input that parses to zero tokens — the empty string, reached through /// the eligibility-free `encode_forced` entry — yields None rather than an /// empty cell vector. From ea202e9bfb52579320e127894244ffcf8724cb4b Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 22:57:08 +0900 Subject: [PATCH 013/132] Accept three symbols the exam papers write and we refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chemistry and engineering papers reach for code points the standard names but this table did not carry, so eight otherwise ordinary lines failed to transcribe at all rather than producing a single wrong cell. Two are the same symbol drawn differently. A reaction arrow is written long, U+27F6, where geometry writes U+2192; an n-ary product is written U+2A09 where arithmetic writes U+00D7. Same meaning, so same cells and same article as the form already present — anything else would report two articles for one role depending on which glyph an author typed. The third was a gap rather than a variant: 제27항 defines 나누어떨어진다 as \ and 나누어떨어지지않는다 as .\, but only the negated form was here. The plain sign is now its undotted counterpart, which a test pins so the pair cannot drift. Nothing already encodable changes: all 221 previous character-to-cell mappings were compared pair by pair and are untouched, and no line that transcribed before transcribes differently. Fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. Over the 5,160 traced papers, failures fall from 18 to 10. --- libs/braillify/src/math_symbol_shortcut.rs | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/libs/braillify/src/math_symbol_shortcut.rs b/libs/braillify/src/math_symbol_shortcut.rs index 176f507c..fb3eb210 100644 --- a/libs/braillify/src/math_symbol_shortcut.rs +++ b/libs/braillify/src/math_symbol_shortcut.rs @@ -204,6 +204,7 @@ static SHORTCUT_MAP: phf::Map = shortcut_map! { '+' => &[decode_unicode('⠢')], '\u{2212}' => &[decode_unicode('⠔')], '\u{00D7}' => &[decode_unicode('⠡')], + '\u{2A09}' => &[decode_unicode('⠡')], '\u{00F7}' => &[decode_unicode('⠌'), decode_unicode('⠌')], '\u{00B1}' => &[decode_unicode('⠢'), decode_unicode('⠔')], }, @@ -234,6 +235,7 @@ static SHORTCUT_MAP: phf::Map = shortcut_map! { }, &META_38 => { '\u{2192}' => &[decode_unicode('⠒'), decode_unicode('⠕')], + '\u{27F6}' => &[decode_unicode('⠒'), decode_unicode('⠕')], '\u{20E1}' => &[decode_unicode('⠪'), decode_unicode('⠒'), decode_unicode('⠕')], }, &META_37 => { @@ -348,6 +350,7 @@ static SHORTCUT_MAP: phf::Map = shortcut_map! { '\u{221A}' => &[decode_unicode('⠜')], }, &META_27 => { + '\u{2223}' => &[decode_unicode('⠳')], '\u{2224}' => &[decode_unicode('⠨'), decode_unicode('⠳')], }, &META_39 => { @@ -571,6 +574,30 @@ mod test { assert_eq!(SHORTCUT_MAP[&symbol].fallback_meta.section, section); } + /// Longer and n-ary glyphs of a symbol the standard already defines mean the + /// same thing, so they take the same cells and the same article. Chemistry + /// writes its reaction arrow long and its product sign n-ary, which is the + /// only reason these code points reach us at all. + #[rstest::rstest] + #[case::long_rightwards_arrow('\u{27F6}', '\u{2192}')] + #[case::n_ary_times('\u{2A09}', '\u{00D7}')] + fn a_glyph_variant_matches_the_symbol_it_varies(#[case] variant: char, #[case] base: char) { + assert_eq!(SHORTCUT_MAP[&variant].cells, SHORTCUT_MAP[&base].cells); + assert_eq!( + SHORTCUT_MAP[&variant].fallback_meta.section, + SHORTCUT_MAP[&base].fallback_meta.section + ); + } + + /// 제27항 writes 나누어떨어진다 as `\` and negates it to `.\`, so the plain + /// sign is the negated one without its leading dot. + #[test] + fn divides_is_the_undotted_form_of_does_not_divide() { + let divides = SHORTCUT_MAP[&'\u{2223}'].cells; + let does_not = SHORTCUT_MAP[&'\u{2224}'].cells; + assert_eq!(does_not, [decode_unicode('⠨'), divides[0]]); + } + /// `is_math_symbol_char` true 케이스 — 연산자/그리스/집합/미적분 기호 전체. #[rstest::rstest] // basic operators From bc19e3123add8f57b2de5943a600ca13c5450fe5 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 23:04:37 +0900 Subject: [PATCH 014/132] Read the ohm sign as the omega it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engineering papers write resistance with U+2126 OHM SIGN rather than U+03A9 GREEK CAPITAL OMEGA. Unicode declares the two canonically equivalent — the ohm sign decomposes to omega and nothing else — so they are one character wearing two code points, and no transcription may tell them apart. We told them apart. Five papers carrying \[Ω]\ failed outright while the same text with capital omega transcribed fine. The English side never saw the problem because it normalises before it reads, which is why \R[Ω]\ already worked while a bare \Ω\ did not. The sign now shares omega's cells and article. Folding it in the pipeline instead would have meant normalising ahead of the NFD step 제65항 5 relies on for accented Latin, for a gain of exactly this one character: across the 5,160 papers and all 5,141 fixtures, the ohm sign is the only thing normalisation would have touched. Nothing already encodable changes: the previous 224 mappings were compared pair by pair and are untouched, and no line that transcribed before transcribes differently. Fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. Failures over the traced papers fall from 10 to 5. --- libs/braillify/src/math_symbol_shortcut.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/libs/braillify/src/math_symbol_shortcut.rs b/libs/braillify/src/math_symbol_shortcut.rs index fb3eb210..fa1a9b05 100644 --- a/libs/braillify/src/math_symbol_shortcut.rs +++ b/libs/braillify/src/math_symbol_shortcut.rs @@ -500,6 +500,7 @@ static SHORTCUT_MAP: phf::Map = shortcut_map! { '\u{03A7}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠯')], '\u{03A8}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠽')], '\u{03A9}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠺')], + '\u{2126}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠺')], }, &META_36 => { '\u{2322}' => &[decode_unicode('⠈'), decode_unicode('⠪')], @@ -574,13 +575,15 @@ mod test { assert_eq!(SHORTCUT_MAP[&symbol].fallback_meta.section, section); } - /// Longer and n-ary glyphs of a symbol the standard already defines mean the - /// same thing, so they take the same cells and the same article. Chemistry - /// writes its reaction arrow long and its product sign n-ary, which is the - /// only reason these code points reach us at all. + /// A second code point for a symbol the standard already defines means the + /// same thing, so it takes the same cells and the same article. Chemistry + /// writes its reaction arrow long and its product sign n-ary; the ohm sign + /// is stronger still, being canonically equivalent to capital omega, so + /// Unicode itself forbids treating the two as different characters. #[rstest::rstest] #[case::long_rightwards_arrow('\u{27F6}', '\u{2192}')] #[case::n_ary_times('\u{2A09}', '\u{00D7}')] + #[case::ohm_sign('\u{2126}', '\u{03A9}')] fn a_glyph_variant_matches_the_symbol_it_varies(#[case] variant: char, #[case] base: char) { assert_eq!(SHORTCUT_MAP[&variant].cells, SHORTCUT_MAP[&base].cells); assert_eq!( From ad81860f017529b99b9b664cf3e0b64524b0503c Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 23:12:40 +0900 Subject: [PATCH 015/132] Read the half-width corner bracket as the bracket it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 제49항 defines one 홑낫표. Unicode spells it twice, 「 」 at full width and 「 」 at half width, and a statute quoted in a survey paper used the half-width pair, so the line failed to transcribe while the same words in the full-width pair transcribed fine. The standard's own prose writes it half width — 「통일영어점자 규정」 appears that way in the articles we transcribe from — so refusing the form is refusing the regulation's own typography. Both spellings now take 제49항's cells, pinned by a test that compares the pair rather than restating the cells, so the two can never drift apart. Nothing already encodable changes: no line that transcribed before transcribes differently. Fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. Failures over the 5,160 traced papers fall from 5 to 4. The remaining four are not gaps we may close by inference. Circled capitals Ⓐ Ⓑ need 제64항's wrapping combined with 제28항's capital sign, and the article demonstrates only the lowercase ⓐ, leaving the order of the two indicators undetermined; that is now a question for 국립국어원 rather than a guess. ≫, ℧ and ℑ appear nowhere in the standard at all. --- libs/braillify/src/symbol_shortcut.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/libs/braillify/src/symbol_shortcut.rs b/libs/braillify/src/symbol_shortcut.rs index e22f83c2..6e1a0023 100644 --- a/libs/braillify/src/symbol_shortcut.rs +++ b/libs/braillify/src/symbol_shortcut.rs @@ -47,6 +47,8 @@ static SHORTCUT_MAP: phf::Map = phf_map! { ':' => &[decode_unicode('⠐'), decode_unicode('⠂')], '「' => &[decode_unicode('⠐'), decode_unicode('⠦')], '」' => &[decode_unicode('⠴'), decode_unicode('⠂')], + '「' => &[decode_unicode('⠐'), decode_unicode('⠦')], + '」' => &[decode_unicode('⠴'), decode_unicode('⠂')], '『' => &[decode_unicode('⠰'), decode_unicode('⠦')], '』' => &[decode_unicode('⠴'), decode_unicode('⠆')], '/' => &[decode_unicode('⠸'), decode_unicode('⠌')], @@ -208,6 +210,22 @@ mod test { ); } + /// 제49항 defines one 홑낫표. Unicode spells it twice, full width and half + /// width, and the standard's own text uses the half-width form, so the pair + /// must transcribe alike or a quoted statute stops transcribing at all. + #[rstest::rstest] + #[case::opening('「', '「')] + #[case::closing('」', '」')] + fn a_halfwidth_corner_bracket_reads_as_its_fullwidth_twin( + #[case] halfwidth: char, + #[case] fullwidth: char, + ) { + assert_eq!( + encode_char_symbol_shortcut(halfwidth).unwrap(), + encode_char_symbol_shortcut(fullwidth).unwrap() + ); + } + #[test] fn test_encode_english_char_symbol_shortcut_variants() { assert_eq!( From 1341f4e0152202d21d18a60c897fdbb673923d22 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 23:23:05 +0900 Subject: [PATCH 016/132] Make the suite refuse a rule that never named its article MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every rule the tracer can credit should cite the article it implements, and the last few commits settled them one family at a time by reading each file. Reading is how the last one was missed: a search for the placeholders I knew about -- "?", "math", "space" -- cannot find a placeholder nobody thought to look for. The registry now answers instead. Walking every engine's registered rules and demanding each section be an article number, a dotted RUEB section, or "-" for output the standard prescribes without giving it an article, the test found unicode_fraction_encoding filed under the section "fraction". 한글 제47항 governs it, and says so with the very character class the rule handles: "분수는 분수표 /을 사용하여 분모, 분수표, 분자 순으로 적고", worked through as ⅔ → #c/#b. Corpus measurement would never have caught it. The 5,160 traced papers write their fractions in LaTeX, so this rule never fires there; the defect was real and silent at the same time. The one placeholder that remains is listed by name rather than skipped as a class, so adding a new undeclared rule turns the test red -- and so does retiring the last placeholder, which should be a deliberate edit rather than a quiet pass. It stands for the n-ary product sign, which the standard never mentions, and the right double parenthesis, which represents a LaTeX delimiter that prints nothing. No output changes. Fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. --- .../src/rules/korean/rule_fraction.rs | 6 +-- libs/braillify/src/rules/trace.rs | 47 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/libs/braillify/src/rules/korean/rule_fraction.rs b/libs/braillify/src/rules/korean/rule_fraction.rs index aafc4010..82d2b022 100644 --- a/libs/braillify/src/rules/korean/rule_fraction.rs +++ b/libs/braillify/src/rules/korean/rule_fraction.rs @@ -7,10 +7,10 @@ use crate::rules::context::RuleContext; use crate::rules::traits::{BrailleRule, Phase, RuleResult}; pub static META: RuleMeta = RuleMeta { - section: "fraction", + section: "47", subsection: None, name: "unicode_fraction_encoding", - standard_ref: "2024 Korean Braille Standard (fractions)", + standard_ref: "2024 Korean Braille Standard, 제47항", description: "Unicode fraction characters (½, ⅓, ¼, etc.)", }; @@ -81,7 +81,7 @@ mod tests { fn rule_metadata_reports_phase() { let rule = RuleFraction; - assert_eq!(rule.meta().section, "fraction"); + assert_eq!(rule.meta().section, "47"); assert!(matches!(rule.phase(), Phase::CoreEncoding)); } } diff --git a/libs/braillify/src/rules/trace.rs b/libs/braillify/src/rules/trace.rs index 8ea8cc87..e748d0cb 100644 --- a/libs/braillify/src/rules/trace.rs +++ b/libs/braillify/src/rules/trace.rs @@ -634,6 +634,53 @@ mod tests { assert_eq!(rule_meta(first), Some(rules[0])); } + /// A section is an article number (`46`), a dotted RUEB section (`14.6.2`), + /// or `-` for output the standard prescribes without giving it an article, + /// such as the blank between words. Anything else is a rule that never had + /// its article checked. + fn names_an_article(section: &str) -> bool { + section == "-" + || (section.starts_with(|c: char| c.is_ascii_digit()) + && section.ends_with(|c: char| c.is_ascii_digit()) + && section.chars().all(|c| c.is_ascii_digit() || c == '.') + && !section.contains("..")) + } + + /// Every rule the tracer can credit must name the article it implements, so + /// a reader can check the transcription against the standard. Two symbols + /// genuinely have none — `∏`, which the standard never mentions, and `⸩`, + /// which stands in for a LaTeX delimiter that prints nothing — and they are + /// reached through one slot that is allowed to say so. Listing that slot + /// here rather than skipping placeholders means a newly undeclared rule + /// turns this red, and retiring the last placeholder does too. + #[test] + fn every_registered_rule_names_its_article() { + let kinds = [ + RuleKind::Korean, + RuleKind::Token, + RuleKind::Math, + RuleKind::Jamo, + RuleKind::EnglishUeb, + RuleKind::Emitter, + ]; + let unnamed: Vec<(RuleKind, &str, &str)> = kinds + .into_iter() + .flat_map(|kind| { + registered_rules(kind) + .iter() + .map(move |meta| (kind, meta.name, meta.section)) + }) + .filter(|(_, _, section)| !names_an_article(section)) + .collect(); + + assert_eq!( + unnamed, + vec![(RuleKind::Math, "undeclared_math_rule", "?")], + "every registered rule must cite an article; only the documented \ + placeholder may not" + ); + } + /// The UEB partition reserves its first slots for move sources that are not /// rule objects, so a contraction rule's id sits at a fixed offset. #[test] From 7ed62e6323ec0b870aaffe8c954afb2ccade5cb0 Mon Sep 17 00:00:00 2001 From: devfive Date: Mon, 21 Sep 2026 23:39:17 +0900 Subject: [PATCH 017/132] Move three geometry marks to the article that defines them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수학 제35항 to 제39항 give one mark each and in order: 선분 @c, 호 @[, 직선 [3O, 반직선 3O, 각 ?. Three of our marks cited the article next door. The overline that spans two points is 제35항's segment bar; it sat under 제36항, which is the arc. The two-headed arrow drawn above a pair is 제37항's line; it sat under 제38항. The single-headed one is 제38항's ray -- an article whose 붙임 also lends it to vectors -- and it sat under 제39항, which is the angle and nothing else. Nothing caught this because the cells were right either way. A reader checking the transcription against the standard would have been sent to an article that does not mention the mark in front of them, which is the whole failure the tracer exists to prevent. 제23항 was checked and left alone. It gives the bar over a variable, 켤레 복소수 and 평균값, the very same @c cells as the segment bar, so the two are told apart by code point alone: a combining or spacing macron marks a variable, the overline spans a pair of points. A test now says so, because the duplication otherwise looks like something to tidy away. The 제35항 slot also had to be declared in the rule's variant list. Reporting an article a rule never declared is refused by design, and it refused this one -- a fixture failed the moment the article moved without the declaration, which is the invariant doing exactly its job. Attribution only: all 225 character-to-cell mappings were compared pair by pair and are unchanged. Fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. --- libs/braillify/src/math_symbol_shortcut.rs | 51 +++++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/libs/braillify/src/math_symbol_shortcut.rs b/libs/braillify/src/math_symbol_shortcut.rs index fa1a9b05..eed5b9be 100644 --- a/libs/braillify/src/math_symbol_shortcut.rs +++ b/libs/braillify/src/math_symbol_shortcut.rs @@ -50,10 +50,11 @@ math_meta! { (META_32, "32", "math_congruence_symbol", "Congruence symbols"), (META_33, "33", "math_geometric_operator", "Geometric operators"), (META_34, "34", "math_relation_symbol", "Relation symbols and their negations"), - (META_36, "36", "math_segment_symbol", "Segment and arc symbols"), + (META_35, "35", "math_segment_symbol", "Segment bar over two points"), + (META_36, "36", "math_arc_symbol", "Arc symbol"), (META_37, "37", "math_line_symbol", "Bidirectional line symbols"), - (META_38, "38", "math_ray_symbol", "Right-arrow ray symbols"), - (META_39, "39", "math_angle_symbol", "Angle and ray symbols"), + (META_38, "38", "math_ray_symbol", "Ray symbols, also used for vectors"), + (META_39, "39", "math_angle_symbol", "Angle symbol"), (META_40, "40", "math_geometric_shape", "Geometric shapes"), (META_41, "41", "math_perpendicular_symbol", "Perpendicular symbols"), (META_42, "42", "math_similarity_symbol", "Similarity symbols"), @@ -147,6 +148,7 @@ pub(crate) static MATH_SYMBOL_VARIANT_METAS: &[&RuleMeta] = &[ &META_32, &META_33, &META_34, + &META_35, &META_36, &META_37, &META_38, @@ -236,10 +238,11 @@ static SHORTCUT_MAP: phf::Map = shortcut_map! { &META_38 => { '\u{2192}' => &[decode_unicode('⠒'), decode_unicode('⠕')], '\u{27F6}' => &[decode_unicode('⠒'), decode_unicode('⠕')], - '\u{20E1}' => &[decode_unicode('⠪'), decode_unicode('⠒'), decode_unicode('⠕')], + '\u{20D7}' => &[decode_unicode('⠒'), decode_unicode('⠕')], }, &META_37 => { '\u{2194}' => &[decode_unicode('⠪'), decode_unicode('⠒'), decode_unicode('⠕')], + '\u{20E1}' => &[decode_unicode('⠪'), decode_unicode('⠒'), decode_unicode('⠕')], }, &META_10 => { '\u{2190}' => &[decode_unicode('⠪'), decode_unicode('⠒')], @@ -355,7 +358,6 @@ static SHORTCUT_MAP: phf::Map = shortcut_map! { }, &META_39 => { '\u{2220}' => &[decode_unicode('⠹')], - '\u{20D7}' => &[decode_unicode('⠒'), decode_unicode('⠕')], }, &META_41 => { '\u{22A5}' => &[decode_unicode('⠴'), decode_unicode('⠄')], @@ -502,9 +504,11 @@ static SHORTCUT_MAP: phf::Map = shortcut_map! { '\u{03A9}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠺')], '\u{2126}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠺')], }, + &META_35 => { + '\u{203E}' => &[decode_unicode('⠈'), decode_unicode('⠉')], + }, &META_36 => { '\u{2322}' => &[decode_unicode('⠈'), decode_unicode('⠪')], - '\u{203E}' => &[decode_unicode('⠈'), decode_unicode('⠉')], }, &META_64 => { '\u{0302}' => &[decode_unicode('⠈'), decode_unicode('⠈'), decode_unicode('⠢')], @@ -575,6 +579,41 @@ mod test { assert_eq!(SHORTCUT_MAP[&symbol].fallback_meta.section, section); } + /// 제35항 to 제39항 run 선분 `@c`, 호 `@[`, 직선 `[3O`, 반직선 `3O`, 각 `?`, + /// one article each and in that order. Three of these marks sat one article + /// away from the one that defines them, which nothing caught because the + /// cells were right either way. The overline is the segment bar of 제35항, + /// not 제36항's arc; the two-headed arrow above a pair is 제37항's line, not + /// a ray; and the single-headed one is 제38항's ray, which its 붙임 also + /// lends to vectors, rather than 제39항's angle. + #[rstest::rstest] + #[case::segment_bar('\u{203E}', "35")] + #[case::arc('\u{2322}', "36")] + #[case::line_above('\u{20E1}', "37")] + #[case::line_arrow('\u{2194}', "37")] + #[case::ray_above('\u{20D7}', "38")] + #[case::ray_arrow('\u{2192}', "38")] + #[case::angle('\u{2220}', "39")] + fn geometry_marks_cite_the_article_that_defines_them( + #[case] symbol: char, + #[case] section: &str, + ) { + assert_eq!(SHORTCUT_MAP[&symbol].fallback_meta.section, section); + } + + /// 제23항 gives the bar over a variable — 켤레 복소수 and 평균값 — the same + /// `@c` cells as 제35항's segment bar, so the two are told apart by code + /// point alone: a combining or spacing macron marks a variable, while the + /// overline spans a pair of points. + #[rstest::rstest] + #[case::combining_macron('\u{0304}')] + #[case::combining_overline('\u{0305}')] + #[case::spacing_macron('\u{00AF}')] + fn a_bar_over_a_variable_stays_with_article_23(#[case] symbol: char) { + assert_eq!(SHORTCUT_MAP[&symbol].fallback_meta.section, "23"); + assert_eq!(SHORTCUT_MAP[&symbol].cells, SHORTCUT_MAP[&'\u{203E}'].cells); + } + /// A second code point for a symbol the standard already defines means the /// same thing, so it takes the same cells and the same article. Chemistry /// writes its reaction arrow long and its product sign n-ary; the ohm sign From 77d050f7006fe59bf6a975ff367bab2087be05cd Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 00:07:57 +0900 Subject: [PATCH 018/132] Test the summation blank and the multiplying middle dot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux coverage gate names ten lines nothing reaches. Two of them are reachable behaviour that simply had no test. 제25항 writes a summation's bounds as a group and then leaves a blank before the body, but only when the body runs straight into it -- a summation already followed by a space, or one ending the expression, must not gain a second blank. The blank itself was never exercised. 제53항 reads a middle dot as the multiplication sign when the same expression also composes arithmetically, which is how derivative and product formulas are written. Nothing built a token stream that put a middle dot beside an equals sign, so the test for that condition never ran. Both are driven through the rule directly rather than through a written expression, because the parser reaches these token shapes only from inputs that would exercise a dozen other rules at the same time and prove nothing about these two. The remaining eight lines are not missing tests: they are a blank line, a closing brace, a method signature, two fields of a placeholder static, and a step in the middle of an iterator chain. They need the instrumentation looked at rather than more assertions, and that has to be read off CI because tarpaulin cannot run here -- the workspace needs a system Python for pyo3, and this platform's recorder miscounts by design. No output changes. Fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. --- .../src/rules/math/encoder/symbol_rule.rs | 71 +++++++++++++++++-- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/libs/braillify/src/rules/math/encoder/symbol_rule.rs b/libs/braillify/src/rules/math/encoder/symbol_rule.rs index d829f55a..11a42dbb 100644 --- a/libs/braillify/src/rules/math/encoder/symbol_rule.rs +++ b/libs/braillify/src/rules/math/encoder/symbol_rule.rs @@ -516,7 +516,8 @@ impl MathTokenRule for MathSymbolRule { // ============================================================ #[cfg(test)] mod tests { - use super::super::super::math_token_rule::MathContext; + use super::super::super::math_token_rule::{MathContext, MathTokenResult}; + use super::super::super::parser::MathToken; use super::super::encode_math_expression; use super::super::encode_math_expression_with_context; @@ -537,10 +538,7 @@ mod tests { #[case::unresolved_product('∏', "?")] fn reports_the_selected_symbol_article(#[case] symbol: char, #[case] expected_section: &str) { use super::super::super::encoder::math_engine_for_context; - use super::super::super::math_token_rule::{ - MathEncodeState, MathTokenResult, MathTokenRule, - }; - use super::super::super::parser::MathToken; + use super::super::super::math_token_rule::{MathEncodeState, MathTokenRule}; let context = MathContext::default(); let engine = math_engine_for_context(context); @@ -559,6 +557,69 @@ mod tests { assert_eq!(meta.section, expected_section); } + fn apply_symbol(tokens: &[MathToken]) -> (Vec, MathTokenResult) { + use super::super::super::encoder::math_engine_for_context; + use super::super::super::math_token_rule::{MathEncodeState, MathTokenRule}; + + let context = MathContext::default(); + let mut output = Vec::new(); + let mut state = MathEncodeState::with_context(false, context); + let outcome = super::MathSymbolRule + .apply( + tokens, + 0, + &mut output, + &mut state, + math_engine_for_context(context), + ) + .expect("math symbol should encode"); + (output, outcome) + } + + /// 제25항 writes the summation's bounds in a group and then leaves a blank + /// before the body. A summation that already has a space after it, or that + /// ends the expression, must not gain a second blank. + #[rstest::rstest] + #[case::runs_into_the_body(Some(MathToken::Variable('x')), true)] + #[case::already_spaced(Some(MathToken::Space), false)] + #[case::ends_the_expression(None, false)] + fn a_summation_separates_itself_from_the_body( + #[case] trailing: Option, + #[case] expects_blank: bool, + ) { + use super::super::super::parser::BracketKind; + + let mut tokens = vec![ + MathToken::MathSymbol('\u{03A3}'), + MathToken::OpenParen(BracketKind::MathParen), + MathToken::Variable('n'), + MathToken::Operator('='), + MathToken::Number("1".to_string()), + MathToken::CloseParen(BracketKind::MathParen), + ]; + tokens.extend(trailing); + + let (output, _) = apply_symbol(&tokens); + + assert_eq!(output.last() == Some(&0), expects_blank); + } + + /// 제53항 reads a middle dot as the multiplication sign when the same + /// expression also composes arithmetically, which is how derivative and + /// product formulas are written. + #[test] + fn a_middle_dot_multiplies_inside_an_equation() { + let (output, outcome) = + apply_symbol(&[MathToken::MathSymbol('\u{00B7}'), MathToken::Operator('=')]); + + let MathTokenResult::ConsumedWithMeta { tokens, meta } = outcome else { + panic!("the middle dot did not report selected metadata"); + }; + assert_eq!(tokens, 1); + assert_eq!(meta.section, "53"); + assert!(!output.is_empty()); + } + // ---------------- Specialised prefix arms ---------------- /// Math rule 61: a negation sign keeps its complete two-cell mapping From ec192f9a7c2cf2762328e5c02f70be5f064acb84 Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 12:37:03 +0900 Subject: [PATCH 019/132] =?UTF-8?q?Write=20circled=20capitals=20the=20way?= =?UTF-8?q?=20=EA=B5=AD=EB=A6=BD=EA=B5=AD=EC=96=B4=EC=9B=90=20settled=20th?= =?UTF-8?q?em?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 제64항 wraps a circled character in 7 7 and works the lowercase case through: ⓐ is 70a7, the roman sign then the letter. It never shows a capital, so the order of the roman sign and the capital sign was undetermined and four readings all fitted the wording. Rather than pick one, the question went to 국립국어원, who answered on 2026-09-21: the roman sign first, then the capital sign, then the letter. Ⓐ is therefore ⠶⠴⠠⠁⠶. A test pins the order with that provenance written down, because nothing in the article can be used to check it. The gate that decides whether a character is an enclosed symbol at all listed only digits and lowercase letters, so the capitals never reached the encoder even to fail there. They do now. Exam papers label their choices Ⓐ Ⓑ, and a paper carrying them previously failed to transcribe as a whole. Fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398 all unchanged. --- libs/braillify/src/rules/korean/rule_64.rs | 28 +++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/libs/braillify/src/rules/korean/rule_64.rs b/libs/braillify/src/rules/korean/rule_64.rs index d33b1aa4..ae0f9c9e 100644 --- a/libs/braillify/src/rules/korean/rule_64.rs +++ b/libs/braillify/src/rules/korean/rule_64.rs @@ -37,6 +37,7 @@ pub static META_SQUARE: RuleMeta = RuleMeta { const CIRCLE: u8 = 54; // ⠶ const LETTER_MARKER: u8 = 52; // ⠴ +const CAPITAL_MARKER: u8 = 32; // ⠠ const NUMBER_MARKER: u8 = 60; // ⠼ /// Open marker for square enclosing: ⠸⠦ (cells 56, 38) @@ -81,7 +82,7 @@ const CIRCLED_JAMO: &[(char, char)] = &[ ]; pub fn is_enclosed_symbol(c: char) -> bool { - matches!(c, '①'..='⑳' | 'ⓐ'..='ⓩ') + matches!(c, '①'..='⑳' | 'ⓐ'..='ⓩ' | 'Ⓐ'..='Ⓩ') || CIRCLED_SYLLABLES.iter().any(|(enclosed, _)| *enclosed == c) || CIRCLED_JAMO.iter().any(|(enclosed, _)| *enclosed == c) } @@ -174,6 +175,16 @@ pub fn encode_enclosed_symbol(c: char) -> Result, String> { ])); } + if ('Ⓐ'..='Ⓩ').contains(&c) { + let letter = char::from_u32((c as u32) - ('Ⓐ' as u32) + ('a' as u32)) + .ok_or_else(|| "Invalid enclosed latin letter".to_string())?; + return Ok(wrap_circle(vec![ + LETTER_MARKER, + CAPITAL_MARKER, + english::encode_english(letter)?, + ])); + } + if let Some((_, syllable)) = CIRCLED_SYLLABLES .iter() .find(|(enclosed, _)| *enclosed == c) @@ -294,6 +305,21 @@ mod tests { assert_eq!(to_unicode(&encode_enclosed_symbol('ⓐ').unwrap()), "⠶⠴⠁⠶"); } + /// 제64항 shows only the lowercase ⓐ as 70a7, leaving the order of the two + /// indicators for a capital undetermined. 국립국어원 settled it on + /// 2026-09-21: the roman sign comes first, then the capital sign, then the + /// letter — 7 0 , a 7. + #[rstest::rstest] + #[case::first('Ⓐ', "⠶⠴⠠⠁⠶")] + #[case::last('Ⓩ', "⠶⠴⠠⠵⠶")] + fn encodes_circled_capital(#[case] symbol: char, #[case] expected: &str) { + assert!(is_enclosed_symbol(symbol)); + assert_eq!( + to_unicode(&encode_enclosed_symbol(symbol).unwrap()), + expected + ); + } + #[test] fn encodes_circled_syllable() { assert_eq!(to_unicode(&encode_enclosed_symbol('㉮').unwrap()), "⠶⠫⠶"); From 27ad897b537ce877d2db68f73ad0159791cfc644 Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 13:04:20 +0900 Subject: [PATCH 020/132] Stop transcribing the n-ary product MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standard names the summation in 수학 제25항 and writes it ,.S, which is Greek capital sigma. Nothing names the product. We wrote it ,.P anyway -- Greek capital pi, by analogy -- and marked the article unknown, which reads as an article we merely have not found yet rather than an extension we invented. 국립국어원 answered on 2026-09-21: the product sign cannot be transcribed. So the table no longer carries it, and an expression containing it is refused instead of quietly given cells the standard never granted. The cells being exactly Greek capital pi's is what made borrowing them look reasonable, so a test records that and asserts the character is absent, or the gap invites the same repair again. The summation is untouched and still cites 수학 제25항. Fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. --- libs/braillify/src/math_symbol_shortcut.rs | 26 ++++++++++++------- .../src/rules/math/encoder/symbol_rule.rs | 1 - 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/libs/braillify/src/math_symbol_shortcut.rs b/libs/braillify/src/math_symbol_shortcut.rs index eed5b9be..f026c394 100644 --- a/libs/braillify/src/math_symbol_shortcut.rs +++ b/libs/braillify/src/math_symbol_shortcut.rs @@ -313,7 +313,6 @@ static SHORTCUT_MAP: phf::Map = shortcut_map! { }, &UNDECLARED_MATH_RULE => { '\u{2E29}' => &[decode_unicode('⠄')], - '\u{220F}' => &[decode_unicode('⠠'), decode_unicode('⠨'), decode_unicode('⠏')], }, &META_34 => { '\u{0338}' => &[decode_unicode('⠨')], @@ -553,15 +552,22 @@ mod test { ); } - /// `∏` is written with the cells of Greek capital pi but the standard never - /// names it, and `⸩` stands in for LaTeX's `\right.` null delimiter, which - /// has no printed counterpart for an article to govern. Both keep the - /// placeholder rather than borrowing an article by resemblance. - #[rstest::rstest] - #[case::product('∏')] - #[case::open_ended_delimiter('⸩')] - fn unresolved_shortcuts_keep_the_honest_placeholder(#[case] symbol: char) { - assert_eq!(SHORTCUT_MAP[&symbol].fallback_meta.section, "?"); + /// `⸩` stands in for LaTeX's `\right.` null delimiter, which prints nothing + /// for an article to govern, so it keeps the placeholder rather than + /// borrowing an article by resemblance. + #[test] + fn the_null_delimiter_keeps_the_honest_placeholder() { + assert_eq!(SHORTCUT_MAP[&'⸩'].fallback_meta.section, "?"); + } + + /// 국립국어원 ruled on 2026-09-21 that the n-ary product cannot be + /// transcribed: the standard never mentions it. Its cells are those of + /// Greek capital pi, which makes borrowing them look reasonable and is + /// exactly why the table must not carry it. + #[test] + fn the_n_ary_product_is_not_transcribable() { + assert!(!SHORTCUT_MAP.contains_key(&'∏')); + assert!(encode_char_math_symbol_shortcut('∏').is_err()); } /// Each of these was identified by matching its cells against the notation diff --git a/libs/braillify/src/rules/math/encoder/symbol_rule.rs b/libs/braillify/src/rules/math/encoder/symbol_rule.rs index 11a42dbb..b1c234fc 100644 --- a/libs/braillify/src/rules/math/encoder/symbol_rule.rs +++ b/libs/braillify/src/rules/math/encoder/symbol_rule.rs @@ -535,7 +535,6 @@ mod tests { #[case::root('√', "22")] #[case::set_membership('∈', "60")] #[case::negation_overlay('\u{0338}', "34")] - #[case::unresolved_product('∏', "?")] fn reports_the_selected_symbol_article(#[case] symbol: char, #[case] expected_section: &str) { use super::super::super::encoder::math_engine_for_context; use super::super::super::math_token_rule::{MathEncodeState, MathTokenRule}; From a107694bb384f91c93b8b51e83a425ff7c8b73ac Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 13:29:22 +0900 Subject: [PATCH 021/132] Tell an arrow over two points from an arrow between them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수학 제38항 writes a ray as 3o,,AB and 제37항 writes a line as [3O,,AB: the arrow ahead of both capitals, attached. 제10항 writes the arrows that stand between things -- right, left, up, down and the four diagonals -- and lists the right arrow among them. We sent every right arrow to 제38항 and every two-headed arrow to 제37항, so a reaction equation and an ordinary mapping both claimed to be geometry. The give-away was in our own table: the left, up, down and diagonal arrows all sat under 제10항 while the right arrow sat alone under 제38항, which is not a shape the standard has. The dispatch now asks what the standard's own notation asks: does the arrow come ahead of two capitals with nothing before it. If it does, it is drawn over them and the geometry articles apply. Otherwise it is standing between two things and 제10항 does. 국립국어원 answered on 2026-09-21 that a chemical reaction arrow follows 과학점자규정 제18항, whose text spells the same cells (+ 5, → 3o, ← {3, ⇄ [7O). Telling a reaction equation from any other standing arrow needs a chemistry signal the math engine does not carry, so reaction arrows now reach 제10항 rather than the geometry article they had before -- closer, and honest about what we can determine. The science article is recorded as remaining work. Limits are untouched: the arrow in \lim_{n \to \infty} never reaches this rule. No output changes -- both branches call the same encoder and all 224 character-to-cell mappings were compared pair by pair. Since the cells are identical either way, only an article assertion can catch a regression here, and one now does. Fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. --- libs/braillify/src/math_symbol_shortcut.rs | 28 +++++++-- .../src/rules/math/encoder/symbol_rule.rs | 58 ++++++++++++++++++- 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/libs/braillify/src/math_symbol_shortcut.rs b/libs/braillify/src/math_symbol_shortcut.rs index f026c394..107c24c3 100644 --- a/libs/braillify/src/math_symbol_shortcut.rs +++ b/libs/braillify/src/math_symbol_shortcut.rs @@ -236,15 +236,15 @@ static SHORTCUT_MAP: phf::Map = shortcut_map! { '\u{2236}' => &[decode_unicode('⠐'), decode_unicode('⠂')], }, &META_38 => { - '\u{2192}' => &[decode_unicode('⠒'), decode_unicode('⠕')], - '\u{27F6}' => &[decode_unicode('⠒'), decode_unicode('⠕')], '\u{20D7}' => &[decode_unicode('⠒'), decode_unicode('⠕')], }, &META_37 => { - '\u{2194}' => &[decode_unicode('⠪'), decode_unicode('⠒'), decode_unicode('⠕')], '\u{20E1}' => &[decode_unicode('⠪'), decode_unicode('⠒'), decode_unicode('⠕')], }, &META_10 => { + '\u{2192}' => &[decode_unicode('⠒'), decode_unicode('⠕')], + '\u{27F6}' => &[decode_unicode('⠒'), decode_unicode('⠕')], + '\u{2194}' => &[decode_unicode('⠪'), decode_unicode('⠒'), decode_unicode('⠕')], '\u{2190}' => &[decode_unicode('⠪'), decode_unicode('⠒')], '\u{2191}' => &[decode_unicode('⠰'), decode_unicode('⠒'), decode_unicode('⠕')], '\u{2193}' => &[decode_unicode('⠘'), decode_unicode('⠒'), decode_unicode('⠕')], @@ -596,9 +596,7 @@ mod test { #[case::segment_bar('\u{203E}', "35")] #[case::arc('\u{2322}', "36")] #[case::line_above('\u{20E1}', "37")] - #[case::line_arrow('\u{2194}', "37")] #[case::ray_above('\u{20D7}', "38")] - #[case::ray_arrow('\u{2192}', "38")] #[case::angle('\u{2220}', "39")] fn geometry_marks_cite_the_article_that_defines_them( #[case] symbol: char, @@ -607,6 +605,26 @@ mod test { assert_eq!(SHORTCUT_MAP[&symbol].fallback_meta.section, section); } + /// 제10항 lists the arrows together — right, left, up, down and the four + /// diagonals — so an arrow standing between operands belongs there. The ray + /// of 제38항 is the mark drawn above a pair of points, which Unicode spells + /// as a combining character, not as the arrow one types between them. + #[rstest::rstest] + #[case::right('\u{2192}')] + #[case::long_right('\u{27F6}')] + #[case::both_ways('\u{2194}')] + #[case::left('\u{2190}')] + #[case::up('\u{2191}')] + #[case::down('\u{2193}')] + #[case::upper_left('\u{2196}')] + fn a_standing_arrow_belongs_to_the_arrow_article(#[case] symbol: char) { + assert_eq!(SHORTCUT_MAP[&symbol].fallback_meta.section, "10"); + assert_ne!( + SHORTCUT_MAP[&symbol].fallback_meta.section, + SHORTCUT_MAP[&'\u{20D7}'].fallback_meta.section + ); + } + /// 제23항 gives the bar over a variable — 켤레 복소수 and 평균값 — the same /// `@c` cells as 제35항's segment bar, so the two are told apart by code /// point alone: a combining or spacing macron marks a variable, while the diff --git a/libs/braillify/src/rules/math/encoder/symbol_rule.rs b/libs/braillify/src/rules/math/encoder/symbol_rule.rs index b1c234fc..dca0a9f4 100644 --- a/libs/braillify/src/rules/math/encoder/symbol_rule.rs +++ b/libs/braillify/src/rules/math/encoder/symbol_rule.rs @@ -26,6 +26,15 @@ impl MathSymbolRule { } None } + + /// True iff the arrow at `index` is drawn over a pair of points, which is + /// how 제37항 and 제38항 write a line and a ray: `3o,,AB`, the arrow ahead of + /// both capitals with nothing before it. An arrow with an operand on its + /// left is standing between two things and belongs to 제10항 instead. + fn names_two_points(tokens: &[MathToken], index: usize) -> bool { + matches!(tokens.get(index + 1), Some(MathToken::UpperVariable(_))) + && rule_12::prev_non_space(tokens, index).is_none() + } } /// True iff tokens at `index+1..=index+5` form the `( N , N )` math-paren @@ -330,10 +339,11 @@ impl MathTokenRule for MathSymbolRule { } else if rule_5::is_proportion_symbol(*c) { rule_5::encode_proportion_symbol(*c, result)?; &math_symbol_shortcut::META_5 - } else if rule_37::is_double_arrow_line_symbol(*c) { + } else if rule_37::is_double_arrow_line_symbol(*c) && Self::names_two_points(tokens, index) + { rule_37::encode_double_arrow_line_symbol(*c, result)?; &math_symbol_shortcut::META_37 - } else if rule_38::is_right_arrow_ray_symbol(*c) { + } else if rule_38::is_right_arrow_ray_symbol(*c) && Self::names_two_points(tokens, index) { rule_38::encode_right_arrow_ray_symbol(*c, result)?; &math_symbol_shortcut::META_38 } else if rule_10::is_arrow_symbol(*c) { @@ -603,6 +613,50 @@ mod tests { assert_eq!(output.last() == Some(&0), expects_blank); } + /// 제37항 and 제38항 draw a line and a ray over a pair of points, writing + /// the arrow ahead of both capitals as `3o,,AB`. 제10항 covers the arrow + /// standing between two things, which is how a reaction equation and an + /// ordinary mapping are written. The cells are the same either way, so only + /// the article distinguishes them. + #[rstest::rstest] + #[case::ray_over_points('\u{2192}', 0, "38")] + #[case::line_over_points('\u{2194}', 0, "37")] + #[case::ray_between_operands('\u{2192}', 1, "10")] + #[case::line_between_operands('\u{2194}', 1, "10")] + fn an_arrow_over_points_is_not_an_arrow_between_them( + #[case] arrow: char, + #[case] index: usize, + #[case] section: &str, + ) { + use super::super::super::encoder::math_engine_for_context; + use super::super::super::math_token_rule::{MathEncodeState, MathTokenRule}; + + let mut tokens = vec![MathToken::MathSymbol(arrow), MathToken::UpperVariable('A')]; + if index == 1 { + tokens.insert(0, MathToken::UpperVariable('B')); + } else { + tokens.push(MathToken::UpperVariable('B')); + } + + let context = MathContext::default(); + let mut output = Vec::new(); + let mut state = MathEncodeState::with_context(false, context); + let outcome = super::MathSymbolRule + .apply( + &tokens, + index, + &mut output, + &mut state, + math_engine_for_context(context), + ) + .expect("an arrow should encode"); + + let MathTokenResult::ConsumedWithMeta { meta, .. } = outcome else { + panic!("the arrow did not report selected metadata"); + }; + assert_eq!(meta.section, section); + } + /// 제53항 reads a middle dot as the multiplication sign when the same /// expression also composes arithmetically, which is how derivative and /// product formulas are written. From de65efd61ae7d4fdbeff28e6d7881c054f0179e4 Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 14:58:13 +0900 Subject: [PATCH 022/132] Record that the product sign no longer transcribes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the n-ary product from the symbol table left nine integration snapshots still expecting cells for it. They failed on every platform, and I did not see it because I ran the suite with --lib for the preceding commits, which skips tests/ entirely. The project's gate is the whole suite, and it says so; I narrowed it and this is what that cost. The snapshots record the Ok/Err shape on purpose -- the module says so at the top -- so the fix is to let them record the refusal rather than to delete the cases. Each of the nine now reads err: Invalid character, and nothing else in the two snapshot files moved. The Π(a,b) dispatch is untouched. It matches Greek capital pi, U+03A0, not the product sign, and keeps its own unit test. Whole suite: 5253 + 20 + 8 + 351 + 162 passing, tests/ included this time. --- .../tests/snapshots/coverage_extra__pi_pair_0_100.snap | 4 ++-- .../tests/snapshots/coverage_extra__pi_pair_1_10.snap | 4 ++-- .../tests/snapshots/coverage_extra__pi_pair_2_5.snap | 4 ++-- .../tests/snapshots/coverage_extra__pi_pair_with_x.snap | 4 ++-- .../snapshots2/coverage_extra2__pi_paren_letter_letter.snap | 4 ++-- .../snapshots2/coverage_extra2__pi_paren_number_letter.snap | 4 ++-- .../snapshots2/coverage_extra2__pi_paren_numbers_three.snap | 4 ++-- .../tests/snapshots2/coverage_extra2__pi_with_three_args.snap | 4 ++-- .../tests/snapshots2/coverage_extra2__super_prod_left.snap | 4 ++-- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/libs/braillify/tests/snapshots/coverage_extra__pi_pair_0_100.snap b/libs/braillify/tests/snapshots/coverage_extra__pi_pair_0_100.snap index fe50c0e0..30a914a1 100644 --- a/libs/braillify/tests/snapshots/coverage_extra__pi_pair_0_100.snap +++ b/libs/braillify/tests/snapshots/coverage_extra__pi_pair_0_100.snap @@ -3,5 +3,5 @@ source: libs/braillify/tests/coverage_extra.rs expression: rendered --- input = "∏(0,100)" -unicode = ok: "⠠⠨⠏⠷⠼⠚⠐⠀⠼⠁⠚⠚⠾" -bytes = ok: [32, 40, 15, 55, 60, 26, 16, 0, 60, 1, 26, 26, 62] +unicode = err: Invalid character +bytes = err: Invalid character diff --git a/libs/braillify/tests/snapshots/coverage_extra__pi_pair_1_10.snap b/libs/braillify/tests/snapshots/coverage_extra__pi_pair_1_10.snap index 501b4493..abd0a3dc 100644 --- a/libs/braillify/tests/snapshots/coverage_extra__pi_pair_1_10.snap +++ b/libs/braillify/tests/snapshots/coverage_extra__pi_pair_1_10.snap @@ -3,5 +3,5 @@ source: libs/braillify/tests/coverage_extra.rs expression: rendered --- input = "∏(1,10)" -unicode = ok: "⠠⠨⠏⠷⠼⠁⠐⠀⠼⠁⠚⠾" -bytes = ok: [32, 40, 15, 55, 60, 1, 16, 0, 60, 1, 26, 62] +unicode = err: Invalid character +bytes = err: Invalid character diff --git a/libs/braillify/tests/snapshots/coverage_extra__pi_pair_2_5.snap b/libs/braillify/tests/snapshots/coverage_extra__pi_pair_2_5.snap index 17c4794b..92169dc2 100644 --- a/libs/braillify/tests/snapshots/coverage_extra__pi_pair_2_5.snap +++ b/libs/braillify/tests/snapshots/coverage_extra__pi_pair_2_5.snap @@ -3,5 +3,5 @@ source: libs/braillify/tests/coverage_extra.rs expression: rendered --- input = "∏(2,5)" -unicode = ok: "⠠⠨⠏⠷⠼⠃⠐⠀⠼⠑⠾" -bytes = ok: [32, 40, 15, 55, 60, 3, 16, 0, 60, 17, 62] +unicode = err: Invalid character +bytes = err: Invalid character diff --git a/libs/braillify/tests/snapshots/coverage_extra__pi_pair_with_x.snap b/libs/braillify/tests/snapshots/coverage_extra__pi_pair_with_x.snap index b614fdf1..2f456e26 100644 --- a/libs/braillify/tests/snapshots/coverage_extra__pi_pair_with_x.snap +++ b/libs/braillify/tests/snapshots/coverage_extra__pi_pair_with_x.snap @@ -3,5 +3,5 @@ source: libs/braillify/tests/coverage_extra.rs expression: rendered --- input = "x∏(1,n)" -unicode = ok: "⠭⠠⠨⠏⠷⠼⠁⠐⠀⠝⠾" -bytes = ok: [45, 32, 40, 15, 55, 60, 1, 16, 0, 29, 62] +unicode = err: Invalid character +bytes = err: Invalid character diff --git a/libs/braillify/tests/snapshots2/coverage_extra2__pi_paren_letter_letter.snap b/libs/braillify/tests/snapshots2/coverage_extra2__pi_paren_letter_letter.snap index 257b69d4..f9f3539a 100644 --- a/libs/braillify/tests/snapshots2/coverage_extra2__pi_paren_letter_letter.snap +++ b/libs/braillify/tests/snapshots2/coverage_extra2__pi_paren_letter_letter.snap @@ -3,5 +3,5 @@ source: libs/braillify/tests/coverage_extra2.rs expression: rendered --- input = "∏(i,n)x" -unicode = ok: "⠠⠨⠏⠷⠊⠐⠀⠝⠾⠭" -bytes = ok: [32, 40, 15, 55, 10, 16, 0, 29, 62, 45] +unicode = err: Invalid character +bytes = err: Invalid character diff --git a/libs/braillify/tests/snapshots2/coverage_extra2__pi_paren_number_letter.snap b/libs/braillify/tests/snapshots2/coverage_extra2__pi_paren_number_letter.snap index 915f6023..b643accd 100644 --- a/libs/braillify/tests/snapshots2/coverage_extra2__pi_paren_number_letter.snap +++ b/libs/braillify/tests/snapshots2/coverage_extra2__pi_paren_number_letter.snap @@ -3,5 +3,5 @@ source: libs/braillify/tests/coverage_extra2.rs expression: rendered --- input = "∏(1,n)x" -unicode = ok: "⠠⠨⠏⠷⠼⠁⠐⠀⠝⠾⠭" -bytes = ok: [32, 40, 15, 55, 60, 1, 16, 0, 29, 62, 45] +unicode = err: Invalid character +bytes = err: Invalid character diff --git a/libs/braillify/tests/snapshots2/coverage_extra2__pi_paren_numbers_three.snap b/libs/braillify/tests/snapshots2/coverage_extra2__pi_paren_numbers_three.snap index 74cd49ea..4f6dafd4 100644 --- a/libs/braillify/tests/snapshots2/coverage_extra2__pi_paren_numbers_three.snap +++ b/libs/braillify/tests/snapshots2/coverage_extra2__pi_paren_numbers_three.snap @@ -3,5 +3,5 @@ source: libs/braillify/tests/coverage_extra2.rs expression: rendered --- input = "∏(1,2)x" -unicode = ok: "⠠⠨⠏⠷⠼⠁⠐⠀⠼⠃⠾⠭" -bytes = ok: [32, 40, 15, 55, 60, 1, 16, 0, 60, 3, 62, 45] +unicode = err: Invalid character +bytes = err: Invalid character diff --git a/libs/braillify/tests/snapshots2/coverage_extra2__pi_with_three_args.snap b/libs/braillify/tests/snapshots2/coverage_extra2__pi_with_three_args.snap index d0fe4cad..89dd4d13 100644 --- a/libs/braillify/tests/snapshots2/coverage_extra2__pi_with_three_args.snap +++ b/libs/braillify/tests/snapshots2/coverage_extra2__pi_with_three_args.snap @@ -3,5 +3,5 @@ source: libs/braillify/tests/coverage_extra2.rs expression: rendered --- input = "∏(1,2,3)" -unicode = ok: "⠠⠨⠏⠷⠼⠁⠐⠀⠼⠃⠐⠀⠼⠉⠾" -bytes = ok: [32, 40, 15, 55, 60, 1, 16, 0, 60, 3, 16, 0, 60, 9, 62] +unicode = err: Invalid character +bytes = err: Invalid character diff --git a/libs/braillify/tests/snapshots2/coverage_extra2__super_prod_left.snap b/libs/braillify/tests/snapshots2/coverage_extra2__super_prod_left.snap index c48e0b6a..04c78013 100644 --- a/libs/braillify/tests/snapshots2/coverage_extra2__super_prod_left.snap +++ b/libs/braillify/tests/snapshots2/coverage_extra2__super_prod_left.snap @@ -3,5 +3,5 @@ source: libs/braillify/tests/coverage_extra2.rs expression: rendered --- input = "∏^k j" -unicode = ok: "⠠⠨⠏⠘⠅⠀⠚" -bytes = ok: [32, 40, 15, 24, 5, 0, 26] +unicode = err: Invalid character +bytes = err: Invalid character From 14f7268dc9fb112aa8f92e731dccfd2817d519c9 Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 15:59:30 +0900 Subject: [PATCH 023/132] Name the brace that LaTeX writes with a one-sided delimiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I set out to delete the cell that `\right.` emits. 국립국어원 ruled that nothing printed means nothing transcribed, `\right.` draws no delimiter, and the cell sat under a placeholder article -- every sign pointed one way. Deleting it broke a regulation fixture. 수학 제6항 1 lists the brackets and includes 연립식 괄호, opening `7'` and closing `,7`. `7'` is two cells, ⠶⠄, and LaTeX writes that opening half as `\left\{ ... \right.`. So the cell is the brace's second half, and the sentinel standing for `\right.` carries it. The matrix code already said as much in a comment about `\begin{cases}`; I did not read it before cutting. The sentinel now cites 제6항 instead of the placeholder, which is what it should have cited all along. A test records why a thing named after a right delimiter belongs to the bracket article, so the same deletion is not attempted again. With that, no registered math rule keeps the placeholder and no shortcut carries an unknown article. Over 5,160 traced papers the tracer reports an article for every cell it explains -- the count of unknown articles reaches zero. Output is untouched: all 224 mappings compared pair by pair, no traced line transcribes differently, fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. --- libs/braillify/src/math_symbol_shortcut.rs | 36 +++++++++++----------- libs/braillify/src/rules/math/encoder.rs | 8 +++-- libs/braillify/src/rules/trace.rs | 16 ++++------ 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/libs/braillify/src/math_symbol_shortcut.rs b/libs/braillify/src/math_symbol_shortcut.rs index 107c24c3..7d64da38 100644 --- a/libs/braillify/src/math_symbol_shortcut.rs +++ b/libs/braillify/src/math_symbol_shortcut.rs @@ -1,7 +1,6 @@ use phf::phf_map; use crate::rules::RuleMeta; -use crate::rules::math::math_token_rule::UNDECLARED_MATH_RULE; use crate::unicode::decode_unicode; #[derive(Debug, Clone, Copy)] @@ -29,6 +28,7 @@ math_meta! { (META_3, "3", "math_equality_symbol", "Equality symbols"), (META_4, "4", "math_comparison_symbol", "Comparison symbols"), (META_5, "5", "math_ratio_symbol", "Ratio and proportion symbols"), + (META_6, "6", "math_bracket_symbol", "Brackets, including the simultaneous-equation brace"), (META_7, "7", "math_fraction_symbol", "Fraction notation"), (META_9, "9", "math_repeating_decimal", "Repeating decimal marks"), (META_10, "10", "math_arrow_symbol", "Arrow symbols"), @@ -173,7 +173,7 @@ pub(crate) static MATH_SYMBOL_VARIANT_METAS: &[&RuleMeta] = &[ &META_KOREAN_53, &META_KOREAN_64, &META_KOREAN_69_APPENDIX_2, - &UNDECLARED_MATH_RULE, + &META_6, ]; macro_rules! shortcut_map { @@ -311,7 +311,7 @@ static SHORTCUT_MAP: phf::Map = shortcut_map! { '\u{2099}' => &[decode_unicode('⠰'), decode_unicode('⠝')], '\u{208A}' => &[decode_unicode('⠰'), decode_unicode('⠢')], }, - &UNDECLARED_MATH_RULE => { + &META_6 => { '\u{2E29}' => &[decode_unicode('⠄')], }, &META_34 => { @@ -538,26 +538,26 @@ pub fn is_math_symbol_char(text: char) -> bool { mod test { use super::*; - const UNRESOLVED_SYMBOLS: &[char] = &['∏', '⇏', '≁', 'ᶜ', 'ℛ', '⁄', '⸩']; - + /// Every character the table can encode names the article that grants it + /// those cells. Nothing is exempt: a symbol the standard does not define is + /// absent from the table rather than present with an unknown article. #[test] - fn every_resolved_shortcut_declares_a_real_fallback_article() { - let missing = SHORTCUT_MAP.entries().find(|(symbol, shortcut)| { - !UNRESOLVED_SYMBOLS.contains(symbol) && shortcut.fallback_meta.section == "?" - }); + fn every_shortcut_declares_a_real_article() { + let missing = SHORTCUT_MAP + .entries() + .find(|(_, shortcut)| shortcut.fallback_meta.section == "?"); - assert!( - missing.is_none(), - "resolved shortcut without article: {missing:?}" - ); + assert!(missing.is_none(), "shortcut without article: {missing:?}"); } - /// `⸩` stands in for LaTeX's `\right.` null delimiter, which prints nothing - /// for an article to govern, so it keeps the placeholder rather than - /// borrowing an article by resemblance. + /// 제6항 1 lists 연립식 괄호 as `7'` and closes it with `,7`. LaTeX writes + /// the opening half as `\left\{ ... \right.`, so the sentinel standing for + /// `\right.` carries the brace's second cell and belongs to that article — + /// it is not a delimiter that prints nothing. #[test] - fn the_null_delimiter_keeps_the_honest_placeholder() { - assert_eq!(SHORTCUT_MAP[&'⸩'].fallback_meta.section, "?"); + fn the_simultaneous_equation_brace_cites_article_6() { + assert_eq!(SHORTCUT_MAP[&'⸩'].fallback_meta.section, "6"); + assert_eq!(SHORTCUT_MAP[&'⸩'].cells, [decode_unicode('⠄')]); } /// 국립국어원 ruled on 2026-09-21 that the n-ary product cannot be diff --git a/libs/braillify/src/rules/math/encoder.rs b/libs/braillify/src/rules/math/encoder.rs index 4473bdbf..06624f44 100644 --- a/libs/braillify/src/rules/math/encoder.rs +++ b/libs/braillify/src/rules/math/encoder.rs @@ -1296,8 +1296,12 @@ mod tests { ); } + /// The placeholder exists as the trait's default so a rule that never chose + /// an article is reported as unattributed rather than credited to one. No + /// registered rule keeps it: every article the math engine can report has + /// been checked against the standard. #[test] - fn flattened_registry_has_one_explicit_unresolved_symbol_slot() { + fn no_registered_math_rule_keeps_the_placeholder() { let unresolved = math_rule_registry() .into_iter() .filter(|meta| { @@ -1305,7 +1309,7 @@ mod tests { }) .count(); - assert_eq!(unresolved, 1); + assert_eq!(unresolved, 0); } /// `KoreanWordRule.apply` defensive Skip when token is not KoreanWord. diff --git a/libs/braillify/src/rules/trace.rs b/libs/braillify/src/rules/trace.rs index e748d0cb..9ea620e4 100644 --- a/libs/braillify/src/rules/trace.rs +++ b/libs/braillify/src/rules/trace.rs @@ -646,13 +646,10 @@ mod tests { && !section.contains("..")) } - /// Every rule the tracer can credit must name the article it implements, so - /// a reader can check the transcription against the standard. Two symbols - /// genuinely have none — `∏`, which the standard never mentions, and `⸩`, - /// which stands in for a LaTeX delimiter that prints nothing — and they are - /// reached through one slot that is allowed to say so. Listing that slot - /// here rather than skipping placeholders means a newly undeclared rule - /// turns this red, and retiring the last placeholder does too. + /// Every rule the tracer can credit names the article it implements, so a + /// reader can always check the transcription against the standard. The + /// expected list is empty rather than absent: a newly undeclared rule turns + /// this red, and so does adding a placeholder back. #[test] fn every_registered_rule_names_its_article() { let kinds = [ @@ -675,9 +672,8 @@ mod tests { assert_eq!( unnamed, - vec![(RuleKind::Math, "undeclared_math_rule", "?")], - "every registered rule must cite an article; only the documented \ - placeholder may not" + Vec::new(), + "every registered rule must cite an article" ); } From 44aba6d1b883abf247e82072f471ec97b47bb150 Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 16:31:05 +0900 Subject: [PATCH 024/132] Send the ellipsis inside a formula to the math article MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한글 제53항 writes the ellipsis in prose. 수학 제12항 [붙임 1] claims it back inside an expression -- "쉼표는 " 으로 적고, 줄임표는 ,,, 으로 적는다" -- and 국립국어원 confirmed on 2026-09-21 that an ellipsis in a formula follows the math standard. Both write the same three cells, so nothing in the output could have shown the wrong choice. The rule for an ellipsis met inside math cited the Korean article, which also rendered as 수학 제53항 in the trace, and 수학 제53항 is the derivative. Article 12 is titled 로마자 변수 표기, so its 붙임 carrying the ellipsis is not something a reader would guess; the rule now quotes it. A test pins the section and the 붙임, because only the article separates the two cases. Prose is untouched: an ellipsis outside a formula still goes through its own rule under 한글 제53항. No output changes, all 224 mappings compared pair by pair. Fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. --- libs/braillify/src/math_symbol_shortcut.rs | 39 +++++++++++++++++----- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/libs/braillify/src/math_symbol_shortcut.rs b/libs/braillify/src/math_symbol_shortcut.rs index 7d64da38..3e982485 100644 --- a/libs/braillify/src/math_symbol_shortcut.rs +++ b/libs/braillify/src/math_symbol_shortcut.rs @@ -94,12 +94,16 @@ pub(crate) static META_KOREAN_51: RuleMeta = RuleMeta { standard_ref: "2024 Korean Braille Standard, 한글 제51항", description: "Colon inside math input", }; -pub(crate) static META_KOREAN_53: RuleMeta = RuleMeta { - section: "53", - subsection: None, - name: "korean_ellipsis_in_math", - standard_ref: "2024 Korean Braille Standard, 한글 제53항", - description: "Ellipsis inside math input", +/// 한글 제53항 governs the ellipsis in prose, but 수학 제12항 [붙임 1] claims it +/// back inside an expression — "쉼표는 `"`으로 적고, 줄임표는 `,,,`으로 적는다". +/// Both write ⠠⠠⠠, so only the article tells them apart, and 국립국어원 settled +/// on 2026-09-21 that an ellipsis inside a formula follows the math standard. +pub(crate) static META_12_APPENDIX_1: RuleMeta = RuleMeta { + section: "12", + subsection: Some("붙임 1"), + name: "math_ellipsis", + standard_ref: "2024 Korean Braille Standard, 수학 제12항 [붙임 1]", + description: "Ellipsis inside a mathematical expression", }; pub(crate) static META_KOREAN_59: RuleMeta = RuleMeta { section: "59", @@ -170,7 +174,7 @@ pub(crate) static MATH_SYMBOL_VARIANT_METAS: &[&RuleMeta] = &[ &META_64, &META_65, &META_KOREAN_50, - &META_KOREAN_53, + &META_12_APPENDIX_1, &META_KOREAN_64, &META_KOREAN_69_APPENDIX_2, &META_6, @@ -344,7 +348,7 @@ static SHORTCUT_MAP: phf::Map = shortcut_map! { &META_KOREAN_50 => { '\u{00B7}' => &[decode_unicode('⠐')], }, - &META_KOREAN_53 => { + &META_12_APPENDIX_1 => { '…' => &[decode_unicode('⠠'), decode_unicode('⠠'), decode_unicode('⠠')], '⋯' => &[decode_unicode('⠠'), decode_unicode('⠠'), decode_unicode('⠠')], }, @@ -550,6 +554,25 @@ mod test { assert!(missing.is_none(), "shortcut without article: {missing:?}"); } + /// An ellipsis writes ⠠⠠⠠ whether it falls in prose or in a formula, so the + /// article is the only thing that separates them: 수학 제12항 [붙임 1] inside + /// an expression, 한글 제53항 outside it. + #[test] + fn an_ellipsis_in_a_formula_cites_the_math_article() { + let ellipsis = SHORTCUT_MAP[&'…']; + + assert_eq!(ellipsis.fallback_meta.section, "12"); + assert_eq!(ellipsis.fallback_meta.subsection, Some("붙임 1")); + assert_eq!( + ellipsis.cells, + [ + decode_unicode('⠠'), + decode_unicode('⠠'), + decode_unicode('⠠') + ] + ); + } + /// 제6항 1 lists 연립식 괄호 as `7'` and closes it with `,7`. LaTeX writes /// the opening half as `\left\{ ... \right.`, so the sentinel standing for /// `\right.` carries the brace's second cell and belongs to that article — From 8817a73867bc2f57899b73aa90526d18a2ab2105 Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 16:48:47 +0900 Subject: [PATCH 025/132] Read the middle dot in a formula as multiplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The middle dot met inside an expression was filed under 한글 제50항, the punctuation mark. The cells say otherwise: 제50항 writes 가운뎃점 as ⠐⠆, two cells, and this table gives the same character a single ⠐. 수학 제2항 [붙임] is where that single cell comes from -- "점으로 표현된 곱셈 기호는 " 으로 적는다" -- so a dot standing between operands is the multiplication sign, not the punctuation it resembles. The article was wrong twice over. It named the wrong rule, and because the rule runs in the math engine the trace rendered it as 수학 제50항, which is 무한대. A test compares the two tables' cells for the same character, since that difference is the whole evidence and a section number alone does not show it. No output changes, all 224 mappings compared pair by pair. Fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. --- libs/braillify/src/math_symbol_shortcut.rs | 33 ++++++++++++++++------ 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/libs/braillify/src/math_symbol_shortcut.rs b/libs/braillify/src/math_symbol_shortcut.rs index 3e982485..82cd1783 100644 --- a/libs/braillify/src/math_symbol_shortcut.rs +++ b/libs/braillify/src/math_symbol_shortcut.rs @@ -80,12 +80,12 @@ pub(crate) static META_KOREAN_49: RuleMeta = RuleMeta { standard_ref: "2024 Korean Braille Standard, 한글 제49항", description: "Question and exclamation marks inside math input", }; -pub(crate) static META_KOREAN_50: RuleMeta = RuleMeta { - section: "50", - subsection: None, - name: "korean_middle_dot_in_math", - standard_ref: "2024 Korean Braille Standard, 한글 제50항", - description: "Middle dot inside math input", +pub(crate) static META_2_APPENDIX: RuleMeta = RuleMeta { + section: "2", + subsection: Some("붙임"), + name: "math_dot_multiplication", + standard_ref: "2024 Korean Braille Standard, 수학 제2항 [붙임]", + description: "Middle dot written as the multiplication sign", }; pub(crate) static META_KOREAN_51: RuleMeta = RuleMeta { section: "51", @@ -173,7 +173,7 @@ pub(crate) static MATH_SYMBOL_VARIANT_METAS: &[&RuleMeta] = &[ &META_61, &META_64, &META_65, - &META_KOREAN_50, + &META_2_APPENDIX, &META_12_APPENDIX_1, &META_KOREAN_64, &META_KOREAN_69_APPENDIX_2, @@ -345,7 +345,7 @@ static SHORTCUT_MAP: phf::Map = shortcut_map! { &META_KOREAN_69_APPENDIX_2 => { '\u{00B0}' => &[decode_unicode('⠴'), decode_unicode('⠙')], }, - &META_KOREAN_50 => { + &META_2_APPENDIX => { '\u{00B7}' => &[decode_unicode('⠐')], }, &META_12_APPENDIX_1 => { @@ -554,6 +554,23 @@ mod test { assert!(missing.is_none(), "shortcut without article: {missing:?}"); } + /// 한글 제50항's 가운뎃점 is two cells, ⠐⠆. The single ⠐ this table gives the + /// same character is 수학 제2항 [붙임] — "점으로 표현된 곱셈 기호는 `"`으로 + /// 적는다" — so a middle dot met inside a formula is multiplication, not the + /// punctuation mark it looks like. + #[test] + fn a_middle_dot_in_a_formula_is_the_multiplication_sign() { + let dot = SHORTCUT_MAP[&'\u{00B7}']; + + assert_eq!(dot.fallback_meta.section, "2"); + assert_eq!(dot.fallback_meta.subsection, Some("붙임")); + assert_eq!(dot.cells, [decode_unicode('⠐')]); + assert_ne!( + dot.cells, + crate::symbol_shortcut::encode_char_symbol_shortcut('\u{00B7}').unwrap() + ); + } + /// An ellipsis writes ⠠⠠⠠ whether it falls in prose or in a formula, so the /// article is the only thing that separates them: 수학 제12항 [붙임 1] inside /// an expression, 한글 제53항 outside it. From 4e20d3efa4274c8313bc0c06ee8f3ade5c140dbc Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 16:57:46 +0900 Subject: [PATCH 026/132] Say which standard an article belongs to, not which engine ran MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trace decided the series from the engine that produced the cell: math engine, therefore 수학 제N항. That is wrong whenever a rule implements an article from another series, and several do. Circled numbers are 한글 제64항 and the math engine encodes them, so the page read them as 수학 제64항, which is 햇. The degree sign, the colon, the semicolon and the tortoise-shell gloss are the same shape of error. Each rule already records the full citation in standard_ref -- "2024 Korean Braille Standard, 한글 제64항" -- and nothing exposed it. The span now carries standard_ref and subsection alongside the number, and the label reads the series from the citation, falling back to the engine only when the citation does not name one. The article data was right the whole time; only the display inferred. No cells change and no rule's article changes. Fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. --- apps/landing/src/app/RuleTrace.tsx | 20 +++++++++++++++----- packages/node/src/lib.rs | 13 +++++++++++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/landing/src/app/RuleTrace.tsx b/apps/landing/src/app/RuleTrace.tsx index 6f57f0ed..172446fa 100644 --- a/apps/landing/src/app/RuleTrace.tsx +++ b/apps/landing/src/app/RuleTrace.tsx @@ -28,6 +28,7 @@ const NO_RULE_NOTICE: Record = { /** 출력 점자의 일부를 만들어 낸 규칙 하나. WASM 객체를 평범한 값으로 옮긴 것. */ export interface TraceRule { section: string + standardRef: string name: string description: string kind: string @@ -87,6 +88,7 @@ export function readTrace(result: TraceResult): TraceSnapshot { path: result.path, rules: spans.map((span) => ({ section: span.section, + standardRef: span.standard_ref, name: span.name, description: span.description, kind: span.kind, @@ -101,16 +103,24 @@ export function readTrace(result: TraceResult): TraceSnapshot { } /** - * 항 번호 표기. `-`는 규정 항이 없는 구조 출력이고 `?`는 항 번호를 아직 - * 선언하지 않은 규칙이다. 둘 다 `제N항`으로 꾸며내지 않는다. + * 항 번호 표기. `-`는 규정 항이 없는 구조 출력이라 `제N항`으로 꾸며내지 않는다. * * 영어는 한국 점자 규정이 아니라 UEB 규정을 따르므로 `제N항`이 아닌 `§N` 표기를 * 쓴다. 수학은 같은 규정 안의 별도 장이라 한글 제N항과 번호가 겹치므로 `수학`을 * 붙여 구분한다. 번호 체계가 다른 규정을 같은 꼴로 적으면 출처를 잘못 읽게 된다. + * + * 규칙이 도는 엔진과 규칙이 구현하는 규정의 계열은 서로 다를 수 있다. 동그라미 + * 숫자는 수식 안에서 만나도 한글 제64항이고, 수학 제64항은 햇(단위 벡터)이다. + * 그래서 계열은 엔진(`kind`)이 아니라 규칙이 밝힌 출처(`standardRef`)에서 읽는다. */ -function sectionLabel(section: string, kind: string): string | null { +function sectionLabel( + section: string, + kind: string, + standardRef: string, +): string | null { if (section === '-') return null - if (section === '?') return '규정 미표기' + if (standardRef.includes('한글 제')) return `한글 제${section}항` + if (standardRef.includes('수학 제')) return `수학 제${section}항` if (kind === 'english-ueb') return `§${section}` return kind === 'math' ? `수학 제${section}항` : `제${section}항` } @@ -144,7 +154,7 @@ function BrailleCells({ braille }: { braille: string }) { } function RuleRow({ rule }: { rule: TraceRule }) { - const section = sectionLabel(rule.section, rule.kind) + const section = sectionLabel(rule.section, rule.kind, rule.standardRef) return ( Result { #[derive(Clone)] #[wasm_bindgen(getter_with_clone)] pub struct RuleSpan { - /// Article number of the 2024 Korean Braille Standard, `"-"` for structural - /// emitter output and `"?"` for a rule whose article is not yet declared. + /// Article number within the standard, or `"-"` for structural output the + /// standard prescribes without giving it an article, such as the blank + /// between words. pub section: String, + /// Sub-division of the article, such as a 붙임, when the rule implements one. + pub subsection: String, + /// The article in full, naming its series. A rule may run in one engine and + /// implement an article from another — circled numbers are 한글 제64항 even + /// though the math engine encodes them — so the number alone is ambiguous. + pub standard_ref: String, pub name: String, pub description: String, /// Which engine produced it: `korean`, `jamo`, `token`, `math`, @@ -79,6 +86,8 @@ fn rule_span( let range = output.start as usize..output.end as usize; Some(RuleSpan { section: meta.section.to_string(), + subsection: meta.subsection.unwrap_or_default().to_string(), + standard_ref: meta.standard_ref.to_string(), name: meta.name.to_string(), description: meta.description.to_string(), kind: kind_label(kind).to_string(), From 405d86ca145c1ef14b526c0fede9d1d7b7b4e078 Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 17:10:44 +0900 Subject: [PATCH 027/132] Stop reading a Hanja as evidence of a historical text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The middle-Korean detector counted any CJK ideograph as strong evidence that a word belonged to a historical text, alongside the old jamo and the private-use syllables. 국립국어원 answered on 2026-09-21 that the mode follows the 옛글자 -- "옛글자가 들어가면 그 글자에 대하여 그렇게 표기합니다" -- and that a Hanja cannot be transcribed as itself at all, so it is evidence of nothing. A modern sentence quoting one in parentheses -- 플로리다주(州)로, 지천명(知天命)의 -- is ordinary Korean, and the corpus has seven such sentences. The trigger turns out to have been inert: with it gone, the regulation fixtures still pass 5141 of 5141, the corpus still matches on 456,025, the marker bench still reads 838 / 145 / 305 / 398, and all seven Hanja sentences still agree with their reference. It fired only where the old jamo or the private-use syllables were already firing. A test now pins the negative, because historical texts are in fact full of Hanja and the range looks like it belongs. The judgment unit was already the word with a look at its neighbours, which is finer than the sentence the reply describes, so nothing there needed changing. --- .../token_rules/middle_korean_detector.rs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/libs/braillify/src/rules/token_rules/middle_korean_detector.rs b/libs/braillify/src/rules/token_rules/middle_korean_detector.rs index 1f0b7077..bdd1662d 100644 --- a/libs/braillify/src/rules/token_rules/middle_korean_detector.rs +++ b/libs/braillify/src/rules/token_rules/middle_korean_detector.rs @@ -37,8 +37,6 @@ fn is_strong_middle_korean_char(c: char) -> bool { // Hangul Jamo Extended-A/B || (0xA960..=0xA97C).contains(&code) || (0xD7B0..=0xD7FB).contains(&code) - // Hanja in historical contexts - || (0x4E00..=0x9FFF).contains(&code) // Precomposed old Hangul syllables in PUA || (0xE000..=0xF8FF).contains(&code) } @@ -150,6 +148,25 @@ mod tests { }) } + /// 국립국어원 answered on 2026-09-21 that the mode follows the 옛글자 — "옛글자가 + /// 들어가면 그 글자에 대하여 그렇게 표기합니다" — and that 한자 is not + /// transcribable at all, so a Hanja is no evidence of a historical text. A + /// modern sentence that quotes one in parentheses is ordinary Korean. + #[rstest::rstest] + #[case::gloss_in_parentheses("中國")] + #[case::single_hanja("州")] + #[case::reading_then_hanja("지천명")] + fn a_hanja_alone_does_not_enter_middle_korean_mode(#[case] text: &str) { + let tokens = [word(text)]; + let mut state = EncoderState::new(false); + + MiddleKoreanDetectorRule + .apply(&tokens, 0, &mut state) + .expect("middle Korean detector should not fail"); + + assert_ne!(state.current_mode(), EncodingMode::MiddleKorean); + } + #[test] fn entering_middle_korean_mode_from_strong_context_pushes_mode() { let text = std::hint::black_box("ᄒ"); From 0a165d8b95638b7cd5778410247589753e7e72cb Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 17:24:05 +0900 Subject: [PATCH 028/132] Let a cell name every article that decided it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked which article to cite when one decision rests on several, 국립국어원 answered on 2026-09-21: 다 적습니다. A section may now carry a list. The English-context punctuation rule is the case that prompted the question. It does three things at once and each has its own article: 제33항 keeps a comma between Roman and Korean in the Korean shape, 제34항 drops the Roman terminator when brackets or quotes enclose the Roman text, and 제49항 gives the punctuation its cells. It cited 제49항 alone, and its standard_ref pointed at two chapters by number -- Ch.4 Sec.10 + Ch.6 Sec.13 -- which no reader could turn into articles. The registry guard accepts a list without also accepting a rule that never chose an article, and both the page and the trace harness render one as 제33항·제34항·제49항 rather than the 제33, 34, 49항 a naive join would give. Over 5,160 traced papers the rule reports all three articles 2,402 times. No output changes and no other rule's article moves. Fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. --- apps/landing/src/app/RuleTrace.tsx | 26 +++++++++++--- .../src/rules/korean/rule_english_symbol.rs | 11 ++++-- libs/braillify/src/rules/trace.rs | 34 ++++++++++++++----- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/apps/landing/src/app/RuleTrace.tsx b/apps/landing/src/app/RuleTrace.tsx index 172446fa..548afed6 100644 --- a/apps/landing/src/app/RuleTrace.tsx +++ b/apps/landing/src/app/RuleTrace.tsx @@ -112,6 +112,9 @@ export function readTrace(result: TraceResult): TraceSnapshot { * 규칙이 도는 엔진과 규칙이 구현하는 규정의 계열은 서로 다를 수 있다. 동그라미 * 숫자는 수식 안에서 만나도 한글 제64항이고, 수학 제64항은 햇(단위 벡터)이다. * 그래서 계열은 엔진(`kind`)이 아니라 규칙이 밝힌 출처(`standardRef`)에서 읽는다. + * + * 한 판단이 여러 항에 걸칠 때는 `, `로 이어 적는다. 국립국어원 회신(2026-09-21)이 + * 그런 경우 항을 하나만 고르지 말고 "다 적으라"고 했다. */ function sectionLabel( section: string, @@ -119,10 +122,25 @@ function sectionLabel( standardRef: string, ): string | null { if (section === '-') return null - if (standardRef.includes('한글 제')) return `한글 제${section}항` - if (standardRef.includes('수학 제')) return `수학 제${section}항` - if (kind === 'english-ueb') return `§${section}` - return kind === 'math' ? `수학 제${section}항` : `제${section}항` + + const series = standardRef.includes('한글 제') + ? '한글 ' + : standardRef.includes('수학 제') + ? '수학 ' + : kind === 'math' + ? '수학 ' + : '' + + if (kind === 'english-ueb' && series === '') { + return section + .split(', ') + .map((one) => `§${one}`) + .join('·') + } + return section + .split(', ') + .map((one) => `${series}제${one}항`) + .join('·') } /** 반열린 구간 `[start, end)`를 1부터 세는 사람 기준 표기로 옮긴다. */ diff --git a/libs/braillify/src/rules/korean/rule_english_symbol.rs b/libs/braillify/src/rules/korean/rule_english_symbol.rs index beb7115d..ef027af0 100644 --- a/libs/braillify/src/rules/korean/rule_english_symbol.rs +++ b/libs/braillify/src/rules/korean/rule_english_symbol.rs @@ -14,11 +14,16 @@ use crate::rules::traits::{BrailleRule, Phase, RuleResult}; use crate::symbol_shortcut; use crate::utils; +/// Three articles decide together here, and 국립국어원 answered on 2026-09-21 +/// that such a cell should name them all rather than pick one. 제33항 keeps a +/// comma between Roman and Korean in the Korean shape, 제34항 drops the Roman +/// terminator when brackets or quotes enclose the Roman text, and 제49항 gives +/// the punctuation its cells. pub static META: RuleMeta = RuleMeta { - section: "49", - subsection: Some("eng"), + section: "33, 34, 49", + subsection: None, name: "english_symbol_context", - standard_ref: "2024 Korean Braille Standard, Ch.4 Sec.10 + Ch.6 Sec.13", + standard_ref: "2024 Korean Braille Standard, 한글 제33항·제34항·제49항", description: "English-context punctuation rendering with parenthesis tracking", }; diff --git a/libs/braillify/src/rules/trace.rs b/libs/braillify/src/rules/trace.rs index 9ea620e4..0ed1f072 100644 --- a/libs/braillify/src/rules/trace.rs +++ b/libs/braillify/src/rules/trace.rs @@ -635,15 +635,33 @@ mod tests { } /// A section is an article number (`46`), a dotted RUEB section (`14.6.2`), - /// or `-` for output the standard prescribes without giving it an article, - /// such as the blank between words. Anything else is a rule that never had - /// its article checked. + /// several of either separated by `, ` when one decision rests on more than + /// one article, or `-` for output the standard prescribes without giving it + /// an article. Anything else is a rule that never had its article checked. fn names_an_article(section: &str) -> bool { - section == "-" - || (section.starts_with(|c: char| c.is_ascii_digit()) - && section.ends_with(|c: char| c.is_ascii_digit()) - && section.chars().all(|c| c.is_ascii_digit() || c == '.') - && !section.contains("..")) + section == "-" || section.split(", ").all(names_one_article) + } + + fn names_one_article(article: &str) -> bool { + article.starts_with(|c: char| c.is_ascii_digit()) + && article.ends_with(|c: char| c.is_ascii_digit()) + && article.chars().all(|c| c.is_ascii_digit() || c == '.') + && !article.contains("..") + } + + /// 국립국어원 answered on 2026-09-21 that a cell decided by several articles + /// should name them all. A section may therefore carry a list, and the guard + /// has to accept it without also accepting a rule that never chose one. + #[rstest::rstest] + #[case::single("46", true)] + #[case::dotted("14.6.2", true)] + #[case::structural("-", true)] + #[case::several("33, 34, 49", true)] + #[case::placeholder("?", false)] + #[case::a_word("fraction", false)] + #[case::half_a_list("33, ", false)] + fn a_section_is_one_article_or_a_list_of_them(#[case] section: &str, #[case] valid: bool) { + assert_eq!(names_an_article(section), valid); } /// Every rule the tracer can credit names the article it implements, so a From b371b94ab13ab58fbcfc74a726188e43bd486dd0 Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 17:36:08 +0900 Subject: [PATCH 029/132] Require a math rule to name its article The placeholder existed as the trait's default so an unchecked rule reported itself as unattributed instead of borrowing an article. Every math rule now names a real one, which left the default unreachable and the placeholder static never read -- two of the ten lines the coverage gate is holding out for. Making meta required turns the runtime guard into a compile error: a rule without a checked article no longer builds. That is the stronger statement, and it is what the placeholder was standing in for all along. The three dummy rules in the dispatch tests take a stand-in article, marked as such, since they exercise dispatch and never reach the registry. No behaviour changes. Fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. --- libs/braillify/src/rules/math/encoder.rs | 20 +++++----- .../src/rules/math/math_token_rule.rs | 39 +++++++++++-------- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/libs/braillify/src/rules/math/encoder.rs b/libs/braillify/src/rules/math/encoder.rs index 06624f44..06bebe1b 100644 --- a/libs/braillify/src/rules/math/encoder.rs +++ b/libs/braillify/src/rules/math/encoder.rs @@ -1296,20 +1296,18 @@ mod tests { ); } - /// The placeholder exists as the trait's default so a rule that never chose - /// an article is reported as unattributed rather than credited to one. No - /// registered rule keeps it: every article the math engine can report has - /// been checked against the standard. + /// `MathTokenRule::meta` has no default, so a rule without a checked article + /// cannot be written at all. What remains to assert is that every article + /// the engine reports is a real one. #[test] - fn no_registered_math_rule_keeps_the_placeholder() { - let unresolved = math_rule_registry() + fn every_math_rule_reports_a_real_article() { + let unnamed: Vec<&str> = math_rule_registry() .into_iter() - .filter(|meta| { - std::ptr::eq(*meta, &super::super::math_token_rule::UNDECLARED_MATH_RULE) - }) - .count(); + .map(|meta| meta.section) + .filter(|section| section.is_empty() || *section == "?") + .collect(); - assert_eq!(unresolved, 0); + assert_eq!(unnamed, Vec::<&str>::new()); } /// `KoreanWordRule.apply` defensive Skip when token is not KoreanWord. diff --git a/libs/braillify/src/rules/math/math_token_rule.rs b/libs/braillify/src/rules/math/math_token_rule.rs index 427f77d7..8967c036 100644 --- a/libs/braillify/src/rules/math/math_token_rule.rs +++ b/libs/braillify/src/rules/math/math_token_rule.rs @@ -46,27 +46,15 @@ pub enum MathTokenResult { use crate::rules::trace::{RuleId, TraceSink}; -/// Placeholder for a math rule that has not declared its source article yet. -/// Rules keeping this default are reported as unattributed rather than being -/// credited to an article nobody checked against the standard. -pub static UNDECLARED_MATH_RULE: crate::rules::RuleMeta = crate::rules::RuleMeta { - section: "?", - subsection: None, - name: "undeclared_math_rule", - standard_ref: "", - description: "", -}; - /// Plugin interface for math token encoding rules. pub trait MathTokenRule: Send + Sync { /// Rule name for debugging. fn name(&self) -> &'static str; - /// The standard article this rule implements. Defaults to - /// [`UNDECLARED_MATH_RULE`] until someone checks the article against the PDF. - fn meta(&self) -> &'static crate::rules::RuleMeta { - &UNDECLARED_MATH_RULE - } + /// The article of the standard this rule implements. Required rather than + /// defaulted: a rule that has not been checked against the standard should + /// fail to compile, not quietly report an article nobody verified. + fn meta(&self) -> &'static crate::rules::RuleMeta; /// Additional articles this rule can select while dispatching variants. fn variant_metas(&self) -> &'static [&'static crate::rules::RuleMeta] { @@ -366,6 +354,16 @@ mod tests { } } + /// Stand-in article for the dummy rules below. They exercise dispatch and + /// never reach the registry, so the number only has to be well formed. + static TEST_META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "1", + subsection: None, + name: "test_rule", + standard_ref: "", + description: "", + }; + /// `MathTokenRule::priority()` default implementation returns 100. /// Exercised by a dummy rule that doesn't override `priority()`. /// Drives the default-impl lines 48-50. @@ -373,6 +371,9 @@ mod tests { fn priority_default_impl_returns_100() { struct DummyRule; impl MathTokenRule for DummyRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &TEST_META + } fn name(&self) -> &'static str { "DummyRule" } @@ -485,6 +486,9 @@ mod tests { fn encode_tokens_continues_after_matching_rule_skips() { struct SkippingRule; impl MathTokenRule for SkippingRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &TEST_META + } fn name(&self) -> &'static str { "SkippingRule" } @@ -513,6 +517,9 @@ mod tests { struct ConsumingRule; impl MathTokenRule for ConsumingRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &TEST_META + } fn name(&self) -> &'static str { "ConsumingRule" } From 1c88d4379734fd66c7d8a9b5e5f5ab10fef6d1e7 Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 17:45:49 +0900 Subject: [PATCH 030/132] Collapse three expressions the coverage recorder cannot follow Three of the lines the Linux gate reports are not branches anyone forgot to test. They are a step in the middle of an iterator chain, the closing brace of a for loop, and a condition split across four lines -- positions where the recorder opens a region that the surrounding code never enters on its own. The file already carries a note about this for a multi-line matches!(), so the remedy is the one already used here: write the expression so no such position exists. The ampersand lookahead slices instead of skipping, which is what it meant anyway. The blank-cell scan extends from an iterator rather than pushing inside a loop. The middle-dot test binds its condition before the if. Each is the same computation. Whole suite 5253 + 20 + 8 + 351 + 162 passing, fixtures 5141 of 5141, corpus 456,025, marker bench 838 / 145 / 305 / 398. --- libs/braillify/src/rules/emit.rs | 3 +-- libs/braillify/src/rules/english_ueb/mod.rs | 12 +++++++----- libs/braillify/src/rules/math/encoder/symbol_rule.rs | 9 ++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/libs/braillify/src/rules/emit.rs b/libs/braillify/src/rules/emit.rs index 0e1fa3cc..c5dd87ad 100644 --- a/libs/braillify/src/rules/emit.rs +++ b/libs/braillify/src/rules/emit.rs @@ -778,9 +778,8 @@ fn spaced_ampersand_connects_roman_words(tokens: &[Token<'_>], ampersand_index: return false; } - tokens + tokens[ampersand_index + 1..] .iter() - .skip(ampersand_index + 1) .find_map(|token| match token { Token::Space(_) | Token::Mode(_) => None, Token::Word(word) => Some( diff --git a/libs/braillify/src/rules/english_ueb/mod.rs b/libs/braillify/src/rules/english_ueb/mod.rs index 7cbc27e5..378e0d03 100644 --- a/libs/braillify/src/rules/english_ueb/mod.rs +++ b/libs/braillify/src/rules/english_ueb/mod.rs @@ -241,11 +241,13 @@ fn align_selected(cells: &[u8], records: &[AttributionRecord]) -> Vec { // output the Korean emitter accounts for. It carries no dots, so there is no // other thing it could be. let blank = crate::rules::trace::RuleId::emitter(crate::rules::trace::EmitterRule::WordSpace); - for (index, cell) in cells.iter().enumerate() { - if *cell == 0 { - spans.push((blank, index as u32..index as u32 + 1)); - } - } + spans.extend( + cells + .iter() + .enumerate() + .filter(|(_, cell)| **cell == 0) + .map(|(index, _)| (blank, index as u32..index as u32 + 1)), + ); spans } diff --git a/libs/braillify/src/rules/math/encoder/symbol_rule.rs b/libs/braillify/src/rules/math/encoder/symbol_rule.rs index dca0a9f4..fb00d95b 100644 --- a/libs/braillify/src/rules/math/encoder/symbol_rule.rs +++ b/libs/braillify/src/rules/math/encoder/symbol_rule.rs @@ -272,11 +272,10 @@ impl MathTokenRule for MathSymbolRule { // In derivative/product formulas (제53항), middle dot is used as // multiplication sign when the same expression also contains // arithmetic composition (= or +). - if *c == '\u{00B7}' - && tokens - .iter() - .any(|t| matches!(t, MathToken::Operator('=' | '+'))) - { + let composes_arithmetically = tokens + .iter() + .any(|t| matches!(t, MathToken::Operator('=' | '+'))); + if *c == '\u{00B7}' && composes_arithmetically { rule_2::encode_operator('\u{00D7}', tokens, index, result)?; state.prev_was_number = false; return Ok(MathTokenResult::ConsumedWithMeta { From 852a7ef85ae240458a62b4a784d7dc4077137fee Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 19:09:18 +0900 Subject: [PATCH 031/132] Cover the attribution lines the rule trace added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage gate reports line numbers from a source I was not reading. The publish workflow writes a .rustfmt.toml with max_width = 100000 and runs cargo fmt before tarpaulin, so every multi-line expression is collapsed first and the reported numbers index that collapsed file. I had been opening those numbers in my own tree, which is why my last two commits moved the reported lines around instead of removing them: I was rewriting expressions that were never the uncovered ones. Reproducing the reformat locally named the real lines, and they are all code this branch introduced -- which is also why main sits at 100% with the same surrounding code. TokenRule::meta() gets the same treatment MathTokenRule got: required rather than defaulted. The placeholder it returned was dead, because the twenty-six real rules all declare an article and the ten dummies in token_engine never have meta() called on them. Requiring it deletes the static and moves the guarantee to the compiler. The other four are reachable and now have tests. A measured quantity such as 3cm is emitted in one piece, so the emitter records its span itself; the raw token rule refuses punctuation outside the four marks the Korean articles name; an indicator landing inside a rule's cells keeps the fragment before it; and a token that wrote no cells records no span at all, since a zero-width span would claim an output position the token never wrote. Whole suite: 5270 + 20 + 8 + 351 + 162 passing, no new warnings. Two lines remain, both in the math symbol chain, and they are a real defect rather than a test gap: the arms for the proportion sign and the sequence braces call a shortcut table that has no entry for either character, so the call always fails and the article line after it cannot be reached. The standard does define both -- 과학점자 제29항 gives 비례 기호 as +3, 수학 제24항 gives 수열 as 7A;N7 -- so the fix is the missing table entries, not a deletion. That lands next. --- Cargo.lock | 7 ++-- libs/braillify/src/lib.rs | 20 +++++++++++ libs/braillify/src/rules/emit.rs | 28 +++++++++++++++ libs/braillify/src/rules/english_ueb/mod.rs | 25 +++++++++++++ libs/braillify/src/rules/math/encoder.rs | 24 +++++++++++++ libs/braillify/src/rules/token_engine.rs | 40 +++++++++++++++++++++ libs/braillify/src/rules/token_rule.rs | 21 +++-------- 7 files changed, 145 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1f0c0ed1..304c2f43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,7 +194,7 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "braillify" -version = "2.1.2" +version = "2.2.0" dependencies = [ "anyhow", "assert_cmd", @@ -219,14 +219,14 @@ dependencies = [ [[package]] name = "braillify-c" -version = "0.1.2" +version = "0.2.0" dependencies = [ "braillify", ] [[package]] name = "braillify-go" -version = "2.0.0" +version = "2.0.1" dependencies = [ "braillify", ] @@ -991,6 +991,7 @@ version = "0.1.0" dependencies = [ "braillify", "console_error_panic_hook", + "rstest", "wasm-bindgen", "wasm-bindgen-test", ] diff --git a/libs/braillify/src/lib.rs b/libs/braillify/src/lib.rs index df8c47da..5bbd8b04 100644 --- a/libs/braillify/src/lib.rs +++ b/libs/braillify/src/lib.rs @@ -1589,6 +1589,26 @@ mod trace_tests { ); } + /// A number written straight against an ASCII unit is emitted as one piece, + /// so its cells are credited in the emitter rather than by the character + /// loop that attributes the digits and the letters separately. + #[rstest::rstest] + #[case::centimetre("3cm")] + #[case::kilogram("5kg")] + fn a_measured_quantity_is_credited_to_the_measurement_rule(#[case] input: &str) { + let (cells, trace) = encode_with_trace(input).expect("input must encode"); + + assert!(!cells.is_empty(), "the measurement still encodes"); + assert!( + trace + .events() + .iter() + .any(|e| e.rule.meta().is_some_and(|m| m.name == "measurement_symbols")), + "the measurement cells name their rule: {:?}", + trace.events() + ); + } + /// UEB picks contractions by a cell-minimising search, so only the winning /// path may be credited. Every recorded range must therefore land inside the /// output and name a UEB rule. diff --git a/libs/braillify/src/rules/emit.rs b/libs/braillify/src/rules/emit.rs index c5dd87ad..076ef0be 100644 --- a/libs/braillify/src/rules/emit.rs +++ b/libs/braillify/src/rules/emit.rs @@ -2458,3 +2458,31 @@ mod roman_chain_resume_coverage { assert!(crate::encode_to_unicode(input).is_ok()); } } + +#[cfg(test)] +mod empty_token_span_tests { + use super::record_token_span; + use crate::rules::trace::{EmitterRule, RuleId, Trace, TraceSink}; + + /// A token can consume input without writing a cell. Attributing it anyway + /// would claim an output position the token never wrote, so the span is + /// dropped rather than recorded as empty. + #[test] + fn a_token_that_wrote_no_cells_records_nothing() { + let mut trace = Trace::default(); + let mut sink = Some(TraceSink::new(&mut trace)); + let result = vec![1, 2, 3]; + + record_token_span( + &mut sink, + None, + 0, + &result, + result.len(), + RuleId::emitter(EmitterRule::WordSpace), + ); + drop(sink); + + assert!(trace.events().is_empty(), "{:?}", trace.events()); + } +} diff --git a/libs/braillify/src/rules/english_ueb/mod.rs b/libs/braillify/src/rules/english_ueb/mod.rs index 378e0d03..dd7cd14a 100644 --- a/libs/braillify/src/rules/english_ueb/mod.rs +++ b/libs/braillify/src/rules/english_ueb/mod.rs @@ -1237,3 +1237,28 @@ mod encode_pipeline_tests { assert_eq!(encode_forced(""), None); } } + +#[cfg(test)] +mod indicator_clipping_tests { + use super::push_without_indicators; + use crate::rules::trace::{EmitterRule, RuleId}; + + /// An indicator can land inside the cells a rule produced. The cells before + /// it still belong to that rule, so they are recorded as their own span + /// instead of being surrendered along with the indicator. + #[test] + fn a_span_interrupted_by_an_indicator_keeps_the_part_before_it() { + let rule = RuleId::emitter(EmitterRule::WordSpace); + let mut spans = Vec::new(); + + push_without_indicators(&mut spans, (rule, 0..6), &[(rule, 2..4)]); + + assert_eq!( + spans + .iter() + .map(|(_, range)| range.clone()) + .collect::>(), + vec![0..2, 4..6] + ); + } +} diff --git a/libs/braillify/src/rules/math/encoder.rs b/libs/braillify/src/rules/math/encoder.rs index 06bebe1b..11e3cac4 100644 --- a/libs/braillify/src/rules/math/encoder.rs +++ b/libs/braillify/src/rules/math/encoder.rs @@ -516,6 +516,30 @@ mod tests { assert!(result.is_ok(), "Should encode ax+b=0: {:?}", result); } + /// The raw rule carries only the four punctuation marks the Korean articles + /// name. A mark outside that list has no article behind it, so it is refused + /// rather than borrowed from another context. + #[test] + fn a_raw_character_outside_the_named_punctuation_is_refused() { + let context = MathContext::default(); + let tokens = [MathToken::Raw('@')]; + let mut result = Vec::new(); + let mut state = MathEncodeState::with_context(false, context); + + let outcome = RawTokenRule.apply( + &tokens, + 0, + &mut result, + &mut state, + math_engine_for_context(context), + ); + + let Err(err) = outcome else { + panic!("an unmapped raw character must not encode"); + }; + assert!(err.contains("Unrecognized math character"), "{err}"); + } + #[test] fn test_number_encoding() { // Pure number should get # prefix diff --git a/libs/braillify/src/rules/token_engine.rs b/libs/braillify/src/rules/token_engine.rs index 78209605..115e5425 100644 --- a/libs/braillify/src/rules/token_engine.rs +++ b/libs/braillify/src/rules/token_engine.rs @@ -156,8 +156,21 @@ mod tests { use super::*; use crate::rules::token::{SpaceKind, WordMeta, WordToken}; + /// Stand-in article for the dummy rules below. They exercise dispatch and + /// never reach the registry, so the number only has to be well formed. + static TEST_META: RuleMeta = RuleMeta { + section: "1", + subsection: None, + name: "test_rule", + standard_ref: "", + description: "", + }; + struct ReplaceWordAt0; impl TokenRule for ReplaceWordAt0 { + fn meta(&self) -> &'static RuleMeta { + &TEST_META + } fn phase(&self) -> TokenPhase { TokenPhase::Normalization } @@ -179,6 +192,9 @@ mod tests { struct InsertSpaceBeforeSecond; impl TokenRule for InsertSpaceBeforeSecond { + fn meta(&self) -> &'static RuleMeta { + &TEST_META + } fn phase(&self) -> TokenPhase { TokenPhase::PostWord } @@ -199,6 +215,9 @@ mod tests { struct RemoveWordB; impl TokenRule for RemoveWordB { + fn meta(&self) -> &'static RuleMeta { + &TEST_META + } fn phase(&self) -> TokenPhase { TokenPhase::PostWord } @@ -219,6 +238,9 @@ mod tests { struct ReplaceManyForB; impl TokenRule for ReplaceManyForB { + fn meta(&self) -> &'static RuleMeta { + &TEST_META + } fn phase(&self) -> TokenPhase { TokenPhase::PostWord } @@ -315,6 +337,9 @@ mod tests { /// empty replacement. struct ReplaceRangeEmpty; impl TokenRule for ReplaceRangeEmpty { + fn meta(&self) -> &'static RuleMeta { + &TEST_META + } fn phase(&self) -> TokenPhase { TokenPhase::Normalization } @@ -356,6 +381,9 @@ mod tests { fn token_engine_noop_normalization_continues_to_next_rule() { struct AlwaysNoop; impl TokenRule for AlwaysNoop { + fn meta(&self) -> &'static RuleMeta { + &TEST_META + } fn phase(&self) -> TokenPhase { TokenPhase::Normalization } @@ -386,6 +414,9 @@ mod tests { fn token_engine_runtime_noop_normalization_continues_to_next_rule() { struct RuntimeNoop; impl TokenRule for RuntimeNoop { + fn meta(&self) -> &'static RuleMeta { + &TEST_META + } fn phase(&self) -> TokenPhase { std::hint::black_box(TokenPhase::Normalization) } @@ -417,6 +448,9 @@ mod tests { fn token_engine_noop_wordshortcut_stops_current_index_rules() { struct WordShortcutNoop; impl TokenRule for WordShortcutNoop { + fn meta(&self) -> &'static RuleMeta { + &TEST_META + } fn phase(&self) -> TokenPhase { TokenPhase::WordShortcut } @@ -435,6 +469,9 @@ mod tests { struct WordShortcutReplace; impl TokenRule for WordShortcutReplace { + fn meta(&self) -> &'static RuleMeta { + &TEST_META + } fn phase(&self) -> TokenPhase { TokenPhase::WordShortcut } @@ -472,6 +509,9 @@ mod tests { struct RewriteB(Rewrite); impl TokenRule for RewriteB { + fn meta(&self) -> &'static RuleMeta { + &TEST_META + } fn phase(&self) -> TokenPhase { TokenPhase::WordShortcut } diff --git a/libs/braillify/src/rules/token_rule.rs b/libs/braillify/src/rules/token_rule.rs index 210c14fd..8ec8144e 100644 --- a/libs/braillify/src/rules/token_rule.rs +++ b/libs/braillify/src/rules/token_rule.rs @@ -2,17 +2,6 @@ use super::RuleMeta; use super::context::EncoderState; use super::token::Token; -/// Placeholder for a token rule that has not declared its source article yet. -/// Rules keeping this default are reported as unattributed rather than being -/// credited to an article nobody checked against the standard. -pub static UNDECLARED_TOKEN_RULE: RuleMeta = RuleMeta { - section: "?", - subsection: None, - name: "undeclared_token_rule", - standard_ref: "", - description: "", -}; - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum TokenPhase { Normalization = 0, @@ -37,12 +26,10 @@ pub enum TokenAction<'a> { } pub trait TokenRule: Send + Sync { - /// The standard article this rule implements. Defaults to - /// [`UNDECLARED_TOKEN_RULE`] so a rule is reported as unattributed until - /// someone checks its article against the PDF. - fn meta(&self) -> &'static RuleMeta { - &UNDECLARED_TOKEN_RULE - } + /// The article of the standard this rule implements. Required rather than + /// defaulted: a rule that has not been checked against the standard should + /// fail to compile, not quietly report an article nobody verified. + fn meta(&self) -> &'static RuleMeta; fn phase(&self) -> TokenPhase; fn priority(&self) -> u16 { From 6f3348d7acf9686ca5a669fd3873e8e489a28883 Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 19:37:43 +0900 Subject: [PATCH 032/132] Give the proportion sign its article and drop the sequence-brace stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last two uncovered lines were the article expressions in the math symbol chain for the proportion sign and the sequence braces. Neither could be reached: both arms call the shortcut table for a character the table has no entry for, so the call always fails and the line after it is dead. The lines are mine -- the per-branch articles this branch introduced -- but the dead arms underneath them are older. The standard does define the proportion sign. 과학 제29항 writes 비례 기호 as +3, with 위치에너지 ∝ 질량 as its example, so the table was simply missing the entry and ∝ was refused as input. It now encodes as ⠬⠒. The article is 과학 제29항, not 수학 제5항: 제5항 is the ratio sign ∶ (U+2236), which the table already carried and which is a different character and a different rule. The arm had been crediting ∝ to 제5항, so a new META_SCIENCE_29 takes it and rule_5's module doc records the split. The sequence braces are not a missing entry. 수학 제24항 writes 수열 as 7A;N7, so the braces of a sequence take ⠶ -- but only there. I added '{' => ⠶ and found out the hard way: it broke set-builder notation, where {x | x ∈ R} must keep the Korean braces ⠦⠂ and ⠐⠴. Braces are article-24 only in the sequence context, and nothing in the encoder detects that context, so a flat mapping is wrong by construction. The arm could therefore never have worked, and the test covering it said as much in its own comment: the parser routes braces to OpenParen, so the arm is not on the path. The stub module goes, and its test now states which path braces actually take. 수학 제24항 stays unimplemented rather than half-implemented. Shortcut table diff against HEAD: 1 added, 0 dot patterns changed. Whole suite: 5269 + 20 + 8 + 351 + 162 passing, no warnings. Corpus unchanged at 456025/467121 (97.6246%). --- libs/braillify/src/math_symbol_shortcut.rs | 13 ++++++- .../src/rules/math/encoder/symbol_rule.rs | 17 +++------ libs/braillify/src/rules/math/mod.rs | 1 - libs/braillify/src/rules/math/rule_24.rs | 38 ------------------- libs/braillify/src/rules/math/rule_5.rs | 5 ++- 5 files changed, 20 insertions(+), 54 deletions(-) delete mode 100644 libs/braillify/src/rules/math/rule_24.rs diff --git a/libs/braillify/src/math_symbol_shortcut.rs b/libs/braillify/src/math_symbol_shortcut.rs index 82cd1783..ba5cfb02 100644 --- a/libs/braillify/src/math_symbol_shortcut.rs +++ b/libs/braillify/src/math_symbol_shortcut.rs @@ -41,7 +41,6 @@ math_meta! { (META_21, "21", "math_absolute_value", "Absolute-value bars"), (META_22, "22", "math_root_symbol", "Root symbols"), (META_23, "23", "math_overline_symbol", "Overline and underline marks"), - (META_24, "24", "math_sequence_brace", "Sequence braces"), (META_25, "25", "math_sigma_symbol", "Summation symbols"), (META_27, "27", "math_divisibility_symbol", "Divisibility symbols"), (META_28, "28", "math_norm_symbol", "Norm symbols"), @@ -126,11 +125,19 @@ pub(crate) static META_KOREAN_69_APPENDIX_2: RuleMeta = RuleMeta { standard_ref: "2024 Korean Braille Standard, 한글 제69항 [붙임 2]", description: "Degree sign inside math input", }; +pub(crate) static META_SCIENCE_29: RuleMeta = RuleMeta { + section: "29", + subsection: None, + name: "science_proportion_symbol", + standard_ref: "2024 Korean Braille Standard, 과학 제29항", + description: "Proportionality sign", +}; pub(crate) static MATH_SYMBOL_VARIANT_METAS: &[&RuleMeta] = &[ &META_2, &META_4, &META_5, + &META_SCIENCE_29, &META_7, &META_9, &META_10, @@ -143,7 +150,6 @@ pub(crate) static MATH_SYMBOL_VARIANT_METAS: &[&RuleMeta] = &[ &META_21, &META_22, &META_23, - &META_24, &META_25, &META_27, &META_28, @@ -239,6 +245,9 @@ static SHORTCUT_MAP: phf::Map = shortcut_map! { &META_5 => { '\u{2236}' => &[decode_unicode('⠐'), decode_unicode('⠂')], }, + &META_SCIENCE_29 => { + '\u{221D}' => &[decode_unicode('⠬'), decode_unicode('⠒')], + }, &META_38 => { '\u{20D7}' => &[decode_unicode('⠒'), decode_unicode('⠕')], }, diff --git a/libs/braillify/src/rules/math/encoder/symbol_rule.rs b/libs/braillify/src/rules/math/encoder/symbol_rule.rs index fb00d95b..4c25faac 100644 --- a/libs/braillify/src/rules/math/encoder/symbol_rule.rs +++ b/libs/braillify/src/rules/math/encoder/symbol_rule.rs @@ -6,7 +6,7 @@ use super::super::math_token_rule::{ use super::super::parser::{BracketKind, MathToken}; use super::super::{ rule_1, rule_2, rule_3, rule_4, rule_5, rule_6, rule_9, rule_10, rule_11, rule_12, rule_13, - rule_15, rule_16, rule_17, rule_21, rule_22, rule_23, rule_24, rule_25, rule_26, rule_27, + rule_15, rule_16, rule_17, rule_21, rule_22, rule_23, rule_25, rule_26, rule_27, rule_28, rule_30, rule_31, rule_32, rule_33, rule_36, rule_37, rule_38, rule_39, rule_40, rule_41, rule_42, rule_43, rule_44, rule_50, rule_54, rule_55, rule_56, rule_58, rule_59, rule_60, rule_61, rule_64, rule_65, @@ -337,7 +337,7 @@ impl MathTokenRule for MathSymbolRule { &math_symbol_shortcut::META_4 } else if rule_5::is_proportion_symbol(*c) { rule_5::encode_proportion_symbol(*c, result)?; - &math_symbol_shortcut::META_5 + &math_symbol_shortcut::META_SCIENCE_29 } else if rule_37::is_double_arrow_line_symbol(*c) && Self::names_two_points(tokens, index) { rule_37::encode_double_arrow_line_symbol(*c, result)?; @@ -375,9 +375,6 @@ impl MathTokenRule for MathSymbolRule { } else if rule_23::is_overline_mark(*c) { rule_23::encode_overline(result)?; &math_symbol_shortcut::META_23 - } else if rule_24::is_sequence_brace(*c) { - rule_24::encode_sequence_brace(*c, result)?; - &math_symbol_shortcut::META_24 } else if rule_27::is_divisibility_symbol(*c) { // `|` is always handled by rule_21::is_absolute_value_bar above; only // U+2224 (∤) reaches this arm. Probe-verified 2026-05-23. @@ -540,6 +537,7 @@ mod tests { #[rstest::rstest] #[case::equality('=', "3")] + #[case::proportion('∝', "29")] #[case::greek('α', "13")] #[case::root('√', "22")] #[case::set_membership('∈', "60")] @@ -921,13 +919,10 @@ mod tests { assert!(!result.is_empty(), "a̅ must encode"); } - /// `{a,b,c}` — sequence brace (U+007B/U+007D) → rule_24 arm at lines - /// 341-342. (Note: parser routes `{` to OpenParen, but a bare math - /// symbol `{` outside grouping context can hit this arm.) + /// The parser routes `{`/`}` to OpenParen/CloseParen, so a brace + /// expression encodes through the bracket path rather than as a symbol. #[test] - fn sequence_brace_dispatch() { - // Use a curly-brace expression — the inner `{`/`}` are parsed as - // OpenParen/CloseParen, but rule_24 still detects them. + fn brace_expression_encodes_through_the_bracket_path() { let result = enc("{a,b}"); assert!(!result.is_empty(), "{{a,b}} must encode"); } diff --git a/libs/braillify/src/rules/math/mod.rs b/libs/braillify/src/rules/math/mod.rs index 721a4490..00a17da2 100644 --- a/libs/braillify/src/rules/math/mod.rs +++ b/libs/braillify/src/rules/math/mod.rs @@ -107,7 +107,6 @@ pub mod rule_20; pub mod rule_21; pub mod rule_22; pub mod rule_23; -pub mod rule_24; pub mod rule_25; pub mod rule_26; pub mod rule_27; diff --git a/libs/braillify/src/rules/math/rule_24.rs b/libs/braillify/src/rules/math/rule_24.rs deleted file mode 100644 index 91c0700d..00000000 --- a/libs/braillify/src/rules/math/rule_24.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! 수학 제24항 — 수열 표기 `{aₙ}`. -//! -//! 수열은 중괄호로 구간을 감싸고 항 기호(예: `aₙ`)를 내부에 배치한다. -//! 인코딩 파이프라인에서는 중괄호 경계와 첨자 정보를 분리해 후속 규칙에 전달한다. - -use crate::math_symbol_shortcut; - -pub fn is_sequence_brace(c: char) -> bool { - matches!(c, '{' | '}') -} - -pub fn encode_sequence_brace(c: char, result: &mut Vec) -> Result<(), String> { - math_symbol_shortcut::encode_char_math_symbol_shortcut(c) - .map(|encoded| result.extend_from_slice(encoded)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn is_sequence_notation_char(c: char) -> bool { - is_sequence_brace(c) || c == '\u{2099}' - } - - #[test] - fn detects_sequence_braces() { - assert!(is_sequence_brace('{')); - assert!(is_sequence_brace('}')); - } - - #[test] - fn detects_sequence_notation_chars() { - assert!(is_sequence_notation_char('{')); - assert!(is_sequence_notation_char('}')); - assert!(is_sequence_notation_char('\u{2099}')); // subscript n - assert!(!is_sequence_notation_char('a')); - } -} diff --git a/libs/braillify/src/rules/math/rule_5.rs b/libs/braillify/src/rules/math/rule_5.rs index b8c66ec5..855f97b4 100644 --- a/libs/braillify/src/rules/math/rule_5.rs +++ b/libs/braillify/src/rules/math/rule_5.rs @@ -1,6 +1,7 @@ -//! 수학 제5항 — 비례식 기호. +//! 과학 제29항 — 비례 기호. //! -//! 비례식에서 사용하는 ∝(U+221D) 기호를 단축표 인코딩으로 준비한다. +//! ∝(U+221D)는 과학 제29항이 `+3`으로 정한 기호다. 수학 제5항의 비 기호 +//! ∶(U+2236)와는 다른 조문이므로 단축표에서도 따로 귀속한다. use crate::math_symbol_shortcut; From 1f0b5f257d70f9633f8dcd19a26393adb9ef934c Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 20:03:08 +0900 Subject: [PATCH 033/132] Satisfy the lint gate the last two commits tripped Both pushes failed before tarpaulin ever ran, so neither measured anything. The lint script is oxlint && cargo fmt --check && cargo clippy -D warnings, and my edits broke the middle and the end of it. cargo fmt disagreed with two places I had hand-written: the trace assertion chain in the measurement test, and the use list in symbol_rule after rule_24 came out of it, which needed rewrapping. clippy caught drop(sink) in the emit test -- Option implements no Drop, so the call only extends a lifetime and says nothing. A plain scope ends the borrow and is what I should have written. I had been checking cargo test and skipping the lint script, which is the same narrowing that cost nine snapshots earlier on this branch. Verified here: cargo fmt --all --check clean, cargo clippy -p braillify --all-targets -D warnings clean, suite 5269 + 20 + 8 + 351 + 162 passing. --- libs/braillify/src/lib.rs | 8 +++---- libs/braillify/src/rules/emit.rs | 22 +++++++++---------- .../src/rules/math/encoder/symbol_rule.rs | 8 +++---- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/libs/braillify/src/lib.rs b/libs/braillify/src/lib.rs index 5bbd8b04..dbbb3761 100644 --- a/libs/braillify/src/lib.rs +++ b/libs/braillify/src/lib.rs @@ -1600,10 +1600,10 @@ mod trace_tests { assert!(!cells.is_empty(), "the measurement still encodes"); assert!( - trace - .events() - .iter() - .any(|e| e.rule.meta().is_some_and(|m| m.name == "measurement_symbols")), + trace.events().iter().any(|e| e + .rule + .meta() + .is_some_and(|m| m.name == "measurement_symbols")), "the measurement cells name their rule: {:?}", trace.events() ); diff --git a/libs/braillify/src/rules/emit.rs b/libs/braillify/src/rules/emit.rs index 076ef0be..97c5b45a 100644 --- a/libs/braillify/src/rules/emit.rs +++ b/libs/braillify/src/rules/emit.rs @@ -2470,18 +2470,18 @@ mod empty_token_span_tests { #[test] fn a_token_that_wrote_no_cells_records_nothing() { let mut trace = Trace::default(); - let mut sink = Some(TraceSink::new(&mut trace)); let result = vec![1, 2, 3]; - - record_token_span( - &mut sink, - None, - 0, - &result, - result.len(), - RuleId::emitter(EmitterRule::WordSpace), - ); - drop(sink); + { + let mut sink = Some(TraceSink::new(&mut trace)); + record_token_span( + &mut sink, + None, + 0, + &result, + result.len(), + RuleId::emitter(EmitterRule::WordSpace), + ); + } assert!(trace.events().is_empty(), "{:?}", trace.events()); } diff --git a/libs/braillify/src/rules/math/encoder/symbol_rule.rs b/libs/braillify/src/rules/math/encoder/symbol_rule.rs index 4c25faac..8c67173a 100644 --- a/libs/braillify/src/rules/math/encoder/symbol_rule.rs +++ b/libs/braillify/src/rules/math/encoder/symbol_rule.rs @@ -6,10 +6,10 @@ use super::super::math_token_rule::{ use super::super::parser::{BracketKind, MathToken}; use super::super::{ rule_1, rule_2, rule_3, rule_4, rule_5, rule_6, rule_9, rule_10, rule_11, rule_12, rule_13, - rule_15, rule_16, rule_17, rule_21, rule_22, rule_23, rule_25, rule_26, rule_27, - rule_28, rule_30, rule_31, rule_32, rule_33, rule_36, rule_37, rule_38, rule_39, rule_40, - rule_41, rule_42, rule_43, rule_44, rule_50, rule_54, rule_55, rule_56, rule_58, rule_59, - rule_60, rule_61, rule_64, rule_65, + rule_15, rule_16, rule_17, rule_21, rule_22, rule_23, rule_25, rule_26, rule_27, rule_28, + rule_30, rule_31, rule_32, rule_33, rule_36, rule_37, rule_38, rule_39, rule_40, rule_41, + rule_42, rule_43, rule_44, rule_50, rule_54, rule_55, rule_56, rule_58, rule_59, rule_60, + rule_61, rule_64, rule_65, }; use super::encode_generic_math_symbol; use crate::math_symbol_shortcut; From 543329d642599ba76aff8f0696a6e7c7527d2cbf Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 20:33:30 +0900 Subject: [PATCH 034/132] Remove the raw-token fallback that nothing can reach One line was left: the trailing Err in RawTokenRule. It only runs when the character is one of ? ! : ; and the Korean symbol table then fails to encode it -- but the match directly above admits exactly those four, and all four are in that table. The lookup therefore always succeeds and the line below it cannot execute. A test could not have covered it; only deleting the branch could. Folding the lookup into the happy path with map_err keeps the error the function would report if an entry ever went missing, while removing the second exit that could not be taken. The earlier match arm keeps its own Err for characters outside the four, which the test added with it still covers. fmt clean, clippy -D warnings clean, suite 5269 + 20 + 8 + 351 + 162 passing. Previous run measured 99.99% with this as the only uncovered line. --- libs/braillify/src/rules/math/encoder.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/libs/braillify/src/rules/math/encoder.rs b/libs/braillify/src/rules/math/encoder.rs index 11e3cac4..9b931b64 100644 --- a/libs/braillify/src/rules/math/encoder.rs +++ b/libs/braillify/src/rules/math/encoder.rs @@ -383,11 +383,10 @@ impl MathTokenRule for RawTokenRule { ';' => &math_symbol_shortcut::META_KOREAN_59, _ => return Err(format!("Unrecognized math character: '{}'", c)), }; - if let Ok(encoded) = crate::symbol_shortcut::encode_char_symbol_shortcut(*c) { - result.extend_from_slice(encoded); - return Ok(MathTokenResult::ConsumedWithMeta { tokens: 1, meta }); - } - Err(format!("Unrecognized math character: '{}'", c)) + let encoded = crate::symbol_shortcut::encode_char_symbol_shortcut(*c) + .map_err(|_| format!("Unrecognized math character: '{}'", c))?; + result.extend_from_slice(encoded); + Ok(MathTokenResult::ConsumedWithMeta { tokens: 1, meta }) } } From 31fe7707c1ae1a8259050bbdbc2fb007b83842be Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 21:38:42 +0900 Subject: [PATCH 035/132] Hand the trace across the wasm boundary as JSON The node tests died at module load with a LinkError naming an import the JS glue plainly exported. My first read blamed Vec, and removing it did move the error -- onto __wbindgen_throw, which every build has. That ruled the vector out and named the real cause: any #[wasm_bindgen] struct makes the module import per-field getters back from its own glue, and Bun cannot link that circular shape. main never hit it because main exports no struct; this branch introduced the first ones with the trace binding. So RuleSpan and TraceResult stop being exported classes and become plain serde structs, and translateToUnicodeWithTrace returns their JSON. The wasm now imports nothing at all -- __wbg_ count is zero, where it was six -- and loads cleanly. The landing side gets simpler for it. readTrace takes the JSON string, parses once, and the objects it hands to React hold no wasm memory, so the per-span free() loop and the TraceResult free() are both gone. Field names are unchanged, so nothing downstream of readTrace moved. bun test 14178 passing, 0 failing -- it was 2 failing before this. cargo test -p node 20 passing, fmt and clippy -D warnings clean. --- Cargo.lock | 2 ++ apps/landing/src/app/RuleTrace.tsx | 36 ++++++++++++++++++-------- packages/node/Cargo.toml | 2 ++ packages/node/src/lib.rs | 41 ++++++++++++++++++++++-------- 4 files changed, 60 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 304c2f43..1d76e7bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -992,6 +992,8 @@ dependencies = [ "braillify", "console_error_panic_hook", "rstest", + "serde", + "serde_json", "wasm-bindgen", "wasm-bindgen-test", ] diff --git a/apps/landing/src/app/RuleTrace.tsx b/apps/landing/src/app/RuleTrace.tsx index 548afed6..867ebffd 100644 --- a/apps/landing/src/app/RuleTrace.tsx +++ b/apps/landing/src/app/RuleTrace.tsx @@ -1,7 +1,6 @@ 'use client' import { Box, Flex, Text, VStack } from '@devup-ui/react' -import type { TraceResult } from 'braillify' /** 한 번에 그리는 규칙 행의 최대 개수. 키 입력마다 다시 그리므로 상한을 둔다. */ const MAX_VISIBLE_RULES = 120 @@ -74,19 +73,39 @@ export const FAILED_TRACE: TraceSnapshot = { path: '', } +interface RuleSpanJson { + section: string + subsection: string + standard_ref: string + name: string + description: string + kind: string + start: number + end: number + braille: string +} + +interface TraceResultJson { + braille: string + rules: RuleSpanJson[] + attributed: number + total: number + path: string +} + /** - * WASM `TraceResult`를 평범한 JS 값으로 복사하고 WASM 쪽 핸들을 해제한다. - * getter 하나하나가 WASM 메모리를 읽으므로 렌더 중에 다시 만지지 않도록 한 번에 옮긴다. + * 점역 결과를 JSON으로 받는다. WASM 쪽은 문자열 하나만 넘기므로 해제할 핸들이 + * 없고, 렌더 중에 WASM 메모리를 다시 읽는 일도 없다. */ -export function readTrace(result: TraceResult): TraceSnapshot { - const spans = result.rules - const snapshot: TraceSnapshot = { +export function readTrace(json: string): TraceSnapshot { + const result = JSON.parse(json) as TraceResultJson + return { status: 'ok', braille: result.braille, attributed: result.attributed, total: result.total, path: result.path, - rules: spans.map((span) => ({ + rules: result.rules.map((span) => ({ section: span.section, standardRef: span.standard_ref, name: span.name, @@ -97,9 +116,6 @@ export function readTrace(result: TraceResult): TraceSnapshot { braille: span.braille, })), } - for (const span of spans) span.free() - result.free() - return snapshot } /** diff --git a/packages/node/Cargo.toml b/packages/node/Cargo.toml index 9171a4ec..2f7361a2 100644 --- a/packages/node/Cargo.toml +++ b/packages/node/Cargo.toml @@ -13,6 +13,8 @@ default = ["console_error_panic_hook"] [dependencies] wasm-bindgen = "0.2.127" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" # UEB Grade-2 English (CMUdict + hyphenation) is a base dependency of braillify, # so it ships even with default-features disabled — the ~3.5 MB table is an # accepted trade-off for correct English in the wasm bundle. diff --git a/packages/node/src/lib.rs b/packages/node/src/lib.rs index a849c5dc..ed0f97c2 100644 --- a/packages/node/src/lib.rs +++ b/packages/node/src/lib.rs @@ -18,8 +18,12 @@ pub fn translate_to_braille_font(text: &str) -> Result { } /// One rule that produced part of the braille output. -#[derive(Clone)] -#[wasm_bindgen(getter_with_clone)] +/// +/// A plain serialisable struct rather than an exported class. Handing a +/// `Vec` of exported structs across the boundary makes wasm-bindgen import a +/// JS constructor into the module, and that import cannot be linked under +/// Bun, which is what runs this package's tests. +#[derive(Clone, serde::Serialize)] pub struct RuleSpan { /// Article number within the standard, or `"-"` for structural output the /// standard prescribes without giving it an article, such as the blank @@ -42,7 +46,11 @@ pub struct RuleSpan { } /// Braille output plus the rules that produced it. -#[wasm_bindgen(getter_with_clone)] +/// +/// Serialised rather than exported as a class, for the reason [`RuleSpan`] +/// gives: an exported struct makes wasm-bindgen import per-field getters into +/// the module, and Bun cannot link those. +#[derive(serde::Serialize)] pub struct TraceResult { pub braille: String, pub rules: Vec, @@ -57,24 +65,33 @@ pub struct TraceResult { } #[wasm_bindgen(js_name = "translateToUnicodeWithTrace")] -pub fn translate_to_unicode_with_trace(text: &str) -> Result { +pub fn translate_to_unicode_with_trace(text: &str) -> Result { + let result = trace_result(text)?; + serde_json::to_string(&result).map_err(|error| error.to_string()) +} + +fn trace_result(text: &str) -> Result { let (cells, trace) = braillify::encode_with_trace(text)?; let braille = to_braille(&cells); - let rules = trace - .events() - .iter() - .filter_map(|event| rule_span(event.rule, event.output.clone(), &cells)) - .collect(); Ok(TraceResult { braille, - rules, + rules: rule_spans(&cells, &trace), attributed: trace.attributed_cells(), total: trace.output_len(), path: path_label(trace.path()).to_string(), }) } +/// Every traced event that names a rule, in output order. +fn rule_spans(cells: &[u8], trace: &braillify::Trace) -> Vec { + trace + .events() + .iter() + .filter_map(|event| rule_span(event.rule, event.output.clone(), cells)) + .collect() +} + /// One event as a span, or `None` for an id the registry does not resolve. fn rule_span( rule: braillify::RuleId, @@ -175,8 +192,10 @@ mod tests { #[test] fn trace_reports_the_rules_behind_the_braille() { - let result = translate_to_unicode_with_trace("안녕").expect("must succeed"); + let json = translate_to_unicode_with_trace("안녕").expect("must succeed"); + let result = trace_result("안녕").expect("must succeed"); + assert!(json.starts_with('{'), "the binding hands back JSON"); assert_eq!(result.path, "korean"); assert_eq!(result.attributed, result.total); assert!(!result.rules.is_empty()); From c02ab742abe3f5534fe66f2d7ee66ccf727eccfa Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 22:30:12 +0900 Subject: [PATCH 036/132] Credit the Nemeth switch indicators to the article that defines them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encode_nemeth_spans appended the opening indicator, the maths between the dollars, the continuation separator and the closing indicator straight onto the output with no record, so every cell of an inline maths switch inside English prose went unexplained. The spreadsheet showed it as whole runs of nothing: on Al3+(aq)$+$3e-… the five cells of ⠸⠩ ⠬ ⠸⠱ named no rule at all, while the blanks around them were picked up only by the word-space emitter. §14.6.2 already owns this output -- it is what the token-level path records for the same construct -- so the four appends now go through it. They are recorded where they are appended rather than reconstructed by a later search, which is the failure mode the rest of this file's attribution has. Spreadsheet: unattributed cells 449 -> 262, rows 15 -> 10, 857427/857689 cells now name an article (99.9695%, was 99.9477%). Whole suite 5269 + 20 + 8 + 351 + 162 passing, so no output moved. What is left is a different gap: prose that spells out literal markup is attributed in some positions and not others, which points at the record-then- search alignment rather than at the emitters. Untouched here. --- libs/braillify/src/rules/english_ueb/rule_14.rs | 15 +++++++++++---- uv.lock | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/libs/braillify/src/rules/english_ueb/rule_14.rs b/libs/braillify/src/rules/english_ueb/rule_14.rs index a60fd7f4..3250e1cd 100644 --- a/libs/braillify/src/rules/english_ueb/rule_14.rs +++ b/libs/braillify/src/rules/english_ueb/rule_14.rs @@ -229,6 +229,13 @@ fn has_nemeth_span(input: &str) -> bool { false } +/// The switch indicators and the maths they wrap are all §14.6.2 output, so +/// they are recorded as they are appended rather than left for a later pass to +/// guess at. +fn push_nemeth(out: &mut Vec, cells: &[u8]) { + super::push_direct(out, super::UebMoveSource::InlineNemethCode, cells); +} + fn encode_nemeth_spans( input: &str, encode_ueb: &mut impl FnMut(&str) -> Option>, @@ -249,16 +256,16 @@ fn encode_nemeth_spans( let after = &rest[start + '$'.len_utf8()..]; let end = after.find('$')?; if !continued { - out.extend(cells("⠸⠩⠀")); + push_nemeth(&mut out, &cells("⠸⠩⠀")); } - out.extend(encode_nemeth_math(&after[..end])?); + push_nemeth(&mut out, &encode_nemeth_math(&after[..end])?); let tail = &after[end + '$'.len_utf8()..]; if tail.starts_with(", $") { - out.extend(cells("⠠⠀")); + push_nemeth(&mut out, &cells("⠠⠀")); rest = &tail[", ".len()..]; continued = true; } else { - out.extend(cells("⠀⠸⠱")); + push_nemeth(&mut out, &cells("⠀⠸⠱")); rest = tail; continued = false; } diff --git a/uv.lock b/uv.lock index a27ec581..3f04fef8 100644 --- a/uv.lock +++ b/uv.lock @@ -11,7 +11,7 @@ members = [ [[package]] name = "braillify" -version = "2.1.2" +version = "2.2.0" source = { editable = "packages/python" } [[package]] From 4fecdac2cfa6b2b5e2dd9d7b5d424b45a13586aa Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 23:27:12 +0900 Subject: [PATCH 037/132] =?UTF-8?q?Let=20a=20record=20say=20where=20it=20w?= =?UTF-8?q?rote,=20and=20record=20the=20=C2=A78.8.2=20capitals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things kept cells of a chemical line from naming a rule, and they had to be fixed together. The §8.8.2 branch that gives a two-letter chemical symbol its capitals one at a time -- KBr, KCl, and here CCl and HCl -- appended the indicator and the letter with a bare push and recorded neither. Those cells had nothing to attribute them to at all. Recording them alone was not enough. align_selected recovers a record's place by searching the finished output for its cells, and a capital indicator is one cell that recurs all over a capitalised line, so the search lands on an earlier occurrence than the one the record wrote. A NonWordAttempt now carries the offset it was written at, and align_selected prefers it when the output still holds those cells there and nothing already claims them. The offset is Option, not usize, on purpose: rule_14's nemeth spans are built in a buffer that is appended elsewhere, so their position is not known at record time and they keep the search. Marking them Some was measurably worse than no offset at all -- a one-cell record matches a wrong offset by accident and takes a place that belonged to another rule. Spreadsheet: unattributed 262 -> 258, rows 10 -> 9, 857431/857689 (99.9699%). Braille output identical on all 5157 rows. Suite 5270 + 20 + 8 + 351 + 162. Two inputs still fall short and are the next test: aMgO(s)$+$… and CO$+$H2O↔…, both lowercase-initial camel words whose subunit capitals come from encode_title_subunit, which builds its cells in a local buffer. Their callers know the offset and should record there. --- .../braillify/src/rules/english_ueb/engine.rs | 2 +- .../rules/english_ueb/engine/word_methods.rs | 12 +- libs/braillify/src/rules/english_ueb/mod.rs | 116 +++++++++++++----- .../src/rules/english_ueb/rule_14.rs | 2 +- 4 files changed, 96 insertions(+), 36 deletions(-) diff --git a/libs/braillify/src/rules/english_ueb/engine.rs b/libs/braillify/src/rules/english_ueb/engine.rs index 19d99ee0..228e006c 100644 --- a/libs/braillify/src/rules/english_ueb/engine.rs +++ b/libs/braillify/src/rules/english_ueb/engine.rs @@ -66,7 +66,7 @@ fn settle_inline_technical_attribution(start: Option<(usize, usize)>, out: &[u8] && out.len() > start { super::rollback_attributions(checkpoint); - super::record_direct(super::UebMoveSource::InlineNemethCode, &out[start..]); + super::record_direct(super::UebMoveSource::InlineNemethCode, &out[start..], start); } } diff --git a/libs/braillify/src/rules/english_ueb/engine/word_methods.rs b/libs/braillify/src/rules/english_ueb/engine/word_methods.rs index f26b2fe7..845e57f0 100644 --- a/libs/braillify/src/rules/english_ueb/engine/word_methods.rs +++ b/libs/braillify/src/rules/english_ueb/engine/word_methods.rs @@ -393,8 +393,16 @@ impl EnglishUebEngine { // better convey the print meaning than a capitals-word indicator plus // terminator. Plural/suffix acronyms (`CDs`, `OKd`) remain under §8.6.3. for &c in &chars[..2] { - out.push(CAPITAL); - out.push(crate::english::encode_english(c.to_ascii_lowercase()).ok()?); + super::super::push_indicator( + out, + super::super::UebMoveSource::CapitalLetterIndicator, + &[CAPITAL], + ); + super::super::push_direct( + out, + super::super::UebMoveSource::Letter, + &[crate::english::encode_english(c.to_ascii_lowercase()).ok()?], + ); } let suffix: Vec = chars[2..].iter().flat_map(|c| c.to_lowercase()).collect(); out.extend( diff --git a/libs/braillify/src/rules/english_ueb/mod.rs b/libs/braillify/src/rules/english_ueb/mod.rs index dd7cd14a..cff2a3e5 100644 --- a/libs/braillify/src/rules/english_ueb/mod.rs +++ b/libs/braillify/src/rules/english_ueb/mod.rs @@ -79,6 +79,12 @@ struct WordAttempt { struct NonWordAttempt { cells: Vec, rule: crate::rules::trace::RuleId, + /// Where the cells were written, when they went straight into the selected + /// output. A one-cell indicator such as `⠠` recurs all over a capitalised + /// line, so looking for it afterwards finds an earlier occurrence than the + /// one this record wrote. `None` marks a record taken against a buffer that + /// is appended elsewhere, whose final position is not known here. + offset: Option, } /// Accumulates the moves of one word-encoding attempt. @@ -185,32 +191,19 @@ fn align_selected(cells: &[u8], records: &[AttributionRecord]) -> Vec { let mut direct_cursor = 0usize; for record in records { if let AttributionRecord::Direct(direct) = record - && let Some(base) = find_from(cells, &direct.cells, direct_cursor) + && let Some(range) = locate(cells, direct, &mut direct_cursor, &[]) { - let end = base + direct.cells.len(); - direct_spans.push((direct.rule, base as u32..end as u32)); - direct_cursor = end; + direct_spans.push((direct.rule, range)); } } let mut indicator_spans = Vec::new(); let mut indicator_cursor = 0usize; for record in records { - match record { - AttributionRecord::Word(_) | AttributionRecord::Direct(_) => {} - AttributionRecord::Indicator(indicator) => { - if let Some(base) = - find_from_outside(cells, &indicator.cells, indicator_cursor, &direct_spans) - { - let end = base + indicator.cells.len(); - push_without_indicators( - &mut indicator_spans, - (indicator.rule, base as u32..end as u32), - &direct_spans, - ); - indicator_cursor = end; - } - } + if let AttributionRecord::Indicator(indicator) = record + && let Some(range) = locate(cells, indicator, &mut indicator_cursor, &direct_spans) + { + push_without_indicators(&mut indicator_spans, (indicator.rule, range), &direct_spans); } } @@ -251,6 +244,36 @@ fn align_selected(cells: &[u8], records: &[AttributionRecord]) -> Vec { spans } +/// Where a record's cells sit in the finished output. +/// +/// The position it was written at wins when the output still carries those +/// cells there and nothing already claims them. Searching is the fallback, and +/// the only option for a record taken against a buffer appended elsewhere. +fn locate( + cells: &[u8], + record: &NonWordAttempt, + cursor: &mut usize, + excluded: &[UebSpan], +) -> Option> { + let len = record.cells.len(); + if let Some(offset) = record.offset + && cells.get(offset..offset + len) == Some(record.cells.as_slice()) + && !excluded + .iter() + .any(|(_, taken)| taken.start < (offset + len) as u32 && (offset as u32) < taken.end) + { + *cursor = offset + len; + return Some(offset as u32..(offset + len) as u32); + } + let base = if excluded.is_empty() { + find_from(cells, &record.cells, *cursor)? + } else { + find_from_outside(cells, &record.cells, *cursor, excluded)? + }; + *cursor = base + len; + Some(base as u32..(base + len) as u32) +} + fn push_without_indicators(spans: &mut Vec, candidate: UebSpan, indicators: &[UebSpan]) { let (rule, range) = candidate; let mut start = range.start; @@ -379,32 +402,42 @@ pub(super) fn record_whole_word(source: UebMoveSource, cells: &[u8]) { } pub(super) fn push_indicator(out: &mut Vec, source: UebMoveSource, cells: &[u8]) { + let offset = Some(out.len()); out.extend_from_slice(cells); - ATTRIBUTIONS.with(|slot| { - if let Ok(mut slot) = slot.try_borrow_mut() - && let Some(records) = slot.as_mut() - { - records.push(AttributionRecord::Indicator(NonWordAttempt { - cells: cells.to_vec(), - rule: crate::rules::trace::RuleId::ueb(source as usize), - })); - } - }); + push_record(source, cells, offset, AttributionRecord::Indicator); } pub(super) fn push_direct(out: &mut Vec, source: UebMoveSource, cells: &[u8]) { + let offset = Some(out.len()); out.extend_from_slice(cells); - record_direct(source, cells); + push_record(source, cells, offset, AttributionRecord::Direct); +} + +/// [`push_direct`] for a buffer that is appended into the output later, where +/// the position here would not be the position the cells end up at. +pub(super) fn push_direct_unplaced(out: &mut Vec, source: UebMoveSource, cells: &[u8]) { + out.extend_from_slice(cells); + push_record(source, cells, None, AttributionRecord::Direct); +} + +pub(super) fn record_direct(source: UebMoveSource, cells: &[u8], offset: usize) { + push_record(source, cells, Some(offset), AttributionRecord::Direct); } -pub(super) fn record_direct(source: UebMoveSource, cells: &[u8]) { +fn push_record( + source: UebMoveSource, + cells: &[u8], + offset: Option, + wrap: fn(NonWordAttempt) -> AttributionRecord, +) { ATTRIBUTIONS.with(|slot| { if let Ok(mut slot) = slot.try_borrow_mut() && let Some(records) = slot.as_mut() { - records.push(AttributionRecord::Direct(NonWordAttempt { + records.push(wrap(NonWordAttempt { cells: cells.to_vec(), rule: crate::rules::trace::RuleId::ueb(source as usize), + offset, })); } }); @@ -1229,6 +1262,25 @@ mod encode_pipeline_tests { ); } + /// §8.8.2 gives a two-letter chemical symbol its capitals one at a time + /// (`CCl`, `HCl`). Those indicators and letters are written straight into + /// the output, so each must claim the cell it wrote. + #[rstest::rstest] + #[case::two_letter_symbols("SO2, CCl4, HCl, $SF_{6}$")] + fn every_cell_of_a_chemical_line_names_a_rule(#[case] input: &str) { + let (cells, trace) = crate::encode_with_trace(input).expect("input must encode"); + let untraced = crate::encode(input).expect("input must encode untraced"); + + assert_eq!(cells, untraced, "trace collection must not change output"); + assert_eq!( + trace.unattributed_cells(), + 0, + "{} of {} cells name no rule", + trace.unattributed_cells(), + cells.len() + ); + } + /// An input that parses to zero tokens — the empty string, reached through /// the eligibility-free `encode_forced` entry — yields None rather than an /// empty cell vector. diff --git a/libs/braillify/src/rules/english_ueb/rule_14.rs b/libs/braillify/src/rules/english_ueb/rule_14.rs index 3250e1cd..c9df4b6c 100644 --- a/libs/braillify/src/rules/english_ueb/rule_14.rs +++ b/libs/braillify/src/rules/english_ueb/rule_14.rs @@ -233,7 +233,7 @@ fn has_nemeth_span(input: &str) -> bool { /// they are recorded as they are appended rather than left for a later pass to /// guess at. fn push_nemeth(out: &mut Vec, cells: &[u8]) { - super::push_direct(out, super::UebMoveSource::InlineNemethCode, cells); + super::push_direct_unplaced(out, super::UebMoveSource::InlineNemethCode, cells); } fn encode_nemeth_spans( From aa49146cf62c3baf99dab17fed302b4ae4308d70 Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 23:50:08 +0900 Subject: [PATCH 038/132] Rebase a word's records when its buffer joins the output A mixed-case word is assembled subunit by subunit in its own buffer, and the capitals between those subunits were pushed into it bare. They had no record, and they could not usefully have one: a record taken while filling that buffer knows only its place inside it, and the buffer has not been appended yet. So the capitals now go through push_indicator against the buffer, and the append rebases every record taken since a checkpoint by where the buffer landed. That is what turns buffer-local positions into ones the finished output can be indexed by. I found the problem by printing out.len() at the append: it was 0 on words that sit well into the line, which is what a buffer-local offset looks like. Spreadsheet: unattributed 258 -> 249, rows 9 -> 8, 857440/857689 (99.9710%). Braille output identical on all 5157 rows. Suite 5272 + 20 + 8 + 351 + 162. The rebase only climbs one level. `aMgO` and `dCO` are now whole, but `bC` and `cMg` in the same line each keep one capital, because the buffer they rebase onto is itself a buffer that is appended further up. The next step is to carry the rebase through that second level rather than stop at the first. --- .../rules/english_ueb/engine/word_methods.rs | 30 ++++++++++++++----- libs/braillify/src/rules/english_ueb/mod.rs | 26 ++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/libs/braillify/src/rules/english_ueb/engine/word_methods.rs b/libs/braillify/src/rules/english_ueb/engine/word_methods.rs index 845e57f0..06b216a8 100644 --- a/libs/braillify/src/rules/english_ueb/engine/word_methods.rs +++ b/libs/braillify/src/rules/english_ueb/engine/word_methods.rs @@ -545,6 +545,7 @@ impl EnglishUebEngine { } bounds.push(chars.len()); + let attributions_before_buf = super::super::attribution_checkpoint(); let mut buf = Vec::new(); let mut prev_caps_word = false; for w in bounds.windows(2) { @@ -613,12 +614,19 @@ impl EnglishUebEngine { // §8.6.3: a §8.4 caps word (`⠠⠠`) is terminated by `⠠⠄` before lowercase // letters that continue the same word (`ABCs`, `WALKing`, `unSELFish`). if prev_caps_word && matches!(caps, Caps::None) { - buf.push(CAPITAL); - buf.push(decode_unicode('⠄')); + super::super::push_indicator( + &mut buf, + super::super::UebMoveSource::CapitalisedWordIndicator, + &[CAPITAL, decode_unicode('⠄')], + ); } if matches!(caps, Caps::Word) && w[0] > 0 && w[1] < chars.len() && seg.len() <= 2 { for cell in &cells { - buf.push(CAPITAL); + super::super::push_indicator( + &mut buf, + super::super::UebMoveSource::CapitalLetterIndicator, + &[CAPITAL], + ); buf.push(*cell); } prev_caps_word = false; @@ -626,16 +634,22 @@ impl EnglishUebEngine { } else { match caps { Caps::None => {} - Caps::Single => buf.push(CAPITAL), - Caps::Word => { - buf.push(CAPITAL); - buf.push(CAPITAL); - } + Caps::Single => super::super::push_indicator( + &mut buf, + super::super::UebMoveSource::CapitalLetterIndicator, + &[CAPITAL], + ), + Caps::Word => super::super::push_indicator( + &mut buf, + super::super::UebMoveSource::CapitalisedWordIndicator, + &[CAPITAL, CAPITAL], + ), } } buf.extend(&cells); prev_caps_word = matches!(caps, Caps::Word); } + super::super::rebase_attributions(attributions_before_buf, out.len()); out.extend(buf); Some(()) } diff --git a/libs/braillify/src/rules/english_ueb/mod.rs b/libs/braillify/src/rules/english_ueb/mod.rs index cff2a3e5..ff524d04 100644 --- a/libs/braillify/src/rules/english_ueb/mod.rs +++ b/libs/braillify/src/rules/english_ueb/mod.rs @@ -172,6 +172,30 @@ fn attribution_checkpoint() -> usize { ATTRIBUTIONS.with(|slot| slot.borrow().as_ref().map_or(0, Vec::len)) } +/// Move records taken since `checkpoint` from a local buffer's coordinates to +/// the output's, once that buffer has been appended at `base`. +/// +/// A word is assembled in its own buffer, so a record made while filling it +/// knows only its place inside that buffer. Rebasing at the append is what +/// turns those into positions the finished output can be indexed by. +fn rebase_attributions(checkpoint: usize, base: usize) { + ATTRIBUTIONS.with(|slot| { + if let Ok(mut slot) = slot.try_borrow_mut() + && let Some(records) = slot.as_mut() + { + for record in records.iter_mut().skip(checkpoint) { + let attempt = match record { + AttributionRecord::Word(_) => continue, + AttributionRecord::Indicator(a) | AttributionRecord::Direct(a) => a, + }; + if let Some(offset) = attempt.offset.as_mut() { + *offset += base; + } + } + } + }); +} + fn rollback_attributions(checkpoint: usize) { ATTRIBUTIONS.with(|slot| { if let Some(records) = slot.borrow_mut().as_mut() { @@ -1267,6 +1291,8 @@ mod encode_pipeline_tests { /// the output, so each must claim the cell it wrote. #[rstest::rstest] #[case::two_letter_symbols("SO2, CCl4, HCl, $SF_{6}$")] + #[case::camel_subunit_word("aMgO")] + #[case::camel_caps_word("dCO")] fn every_cell_of_a_chemical_line_names_a_rule(#[case] input: &str) { let (cells, trace) = crate::encode_with_trace(input).expect("input must encode"); let untraced = crate::encode(input).expect("input must encode untraced"); From 83c84af837d8179bee9e07331d416b2f1264f9d3 Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 22 Sep 2026 23:58:48 +0900 Subject: [PATCH 039/132] Carry the rebase through the prose chunks of a nemeth line encode_nemeth_spans hands each stretch of prose between the dollars to a closure that encodes it into a fresh buffer, then appends the result. The records that closure made were still in that buffer's coordinates, so the rebase added for a word's own buffer only climbed one level and stopped. Rebasing again at each of those appends carries it the rest of the way. The balanced equation aMgO(s)$+$C(s) -> cMg(s)$+$dCO(g)$+$eCO2(g) now names a rule for all 90 of its cells, where two capitals were unexplained before. Spreadsheet: unattributed 249 -> 241, rows 8 -> 7, 857448/857689 (99.9719%). Braille output identical on all 5157 rows. Suite 5272 + 20 + 8 + 351 + 162. --- libs/braillify/src/rules/english_ueb/mod.rs | 1 + .../src/rules/english_ueb/rule_14.rs | 20 ++++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/libs/braillify/src/rules/english_ueb/mod.rs b/libs/braillify/src/rules/english_ueb/mod.rs index ff524d04..70f46a17 100644 --- a/libs/braillify/src/rules/english_ueb/mod.rs +++ b/libs/braillify/src/rules/english_ueb/mod.rs @@ -1293,6 +1293,7 @@ mod encode_pipeline_tests { #[case::two_letter_symbols("SO2, CCl4, HCl, $SF_{6}$")] #[case::camel_subunit_word("aMgO")] #[case::camel_caps_word("dCO")] + #[case::balanced_equation("aMgO(s)$+$bC(s)→cMg(s)$+$dCO(g)$+$eCO2(g)")] fn every_cell_of_a_chemical_line_names_a_rule(#[case] input: &str) { let (cells, trace) = crate::encode_with_trace(input).expect("input must encode"); let untraced = crate::encode(input).expect("input must encode untraced"); diff --git a/libs/braillify/src/rules/english_ueb/rule_14.rs b/libs/braillify/src/rules/english_ueb/rule_14.rs index c9df4b6c..eae7e051 100644 --- a/libs/braillify/src/rules/english_ueb/rule_14.rs +++ b/libs/braillify/src/rules/english_ueb/rule_14.rs @@ -236,6 +236,20 @@ fn push_nemeth(out: &mut Vec, cells: &[u8]) { super::push_direct_unplaced(out, super::UebMoveSource::InlineNemethCode, cells); } +/// Append prose that was encoded into its own buffer, moving the records it +/// made from that buffer's coordinates onto this one's. +fn extend_prose( + out: &mut Vec, + encode_ueb: &mut impl FnMut(&str) -> Option>, + text: &str, +) -> Option<()> { + let checkpoint = super::attribution_checkpoint(); + let cells = encode_ueb(text)?; + super::rebase_attributions(checkpoint, out.len()); + out.extend(cells); + Some(()) +} + fn encode_nemeth_spans( input: &str, encode_ueb: &mut impl FnMut(&str) -> Option>, @@ -245,13 +259,13 @@ fn encode_nemeth_spans( let mut continued = false; while let Some(start) = rest.find('$') { if continued { - out.extend(encode_ueb(&rest[..start])?); + extend_prose(&mut out, encode_ueb, &rest[..start])?; } else if rest[..start].ends_with('"') { let prefix = &rest[..start - '"'.len_utf8()]; - out.extend(encode_ueb(prefix)?); + extend_prose(&mut out, encode_ueb, prefix)?; out.push(decode_unicode('⠦')); } else { - out.extend(encode_ueb(&rest[..start])?); + extend_prose(&mut out, encode_ueb, &rest[..start])?; } let after = &rest[start + '$'.len_utf8()..]; let end = after.find('$')?; From 08df1c0173478c2d76f617f533a228fa6049c8f9 Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 00:04:53 +0900 Subject: [PATCH 040/132] Rebase the tail chunk of a nemeth line too The prose after the last closing indicator went straight through out.extend(encode_ueb(rest)) while every earlier chunk had been switched to the rebasing append. Its records kept their buffer-local offsets, so the capital opening the tail claimed cell 0 instead of the cell it wrote. Folding that branch into extend_prose finishes the cascade. encode_simple_ueb_symbols stays as the fallback for prose the UEB encoder rejects. Spreadsheet: unattributed 241 -> 233, rows 7 -> 4, 857456/857689 (99.9728%). Braille output identical on all 5157 rows. Suite 5273 + 20 + 8 + 351 + 162. What remains is one shape: a word attempt is recorded with its cells but no position, and the two-cell runs of literal markup repeat so often that the forward search settles on the wrong one. Word records would need the same offset treatment the indicator and direct records just got. --- libs/braillify/src/rules/english_ueb/rule_14.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/libs/braillify/src/rules/english_ueb/rule_14.rs b/libs/braillify/src/rules/english_ueb/rule_14.rs index eae7e051..f5190102 100644 --- a/libs/braillify/src/rules/english_ueb/rule_14.rs +++ b/libs/braillify/src/rules/english_ueb/rule_14.rs @@ -284,12 +284,8 @@ fn encode_nemeth_spans( continued = false; } } - if !rest.is_empty() { - if let Some(cells) = encode_ueb(rest) { - out.extend(cells); - } else { - out.extend(encode_simple_ueb_symbols(rest)?); - } + if !rest.is_empty() && extend_prose(&mut out, encode_ueb, rest).is_none() { + out.extend(encode_simple_ueb_symbols(rest)?); } Some(out) } From d913a2c8f20e2e7d70cf2713bb71fcd534da9630 Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 00:22:54 +0900 Subject: [PATCH 041/132] Let a word attempt say where it wrote, too The indicator and direct records learned their position a few commits ago; the word attempts never did. They are still placed by searching the output for their cells, and a chemical line repeats the two-cell runs of literal markup a dozen times, so the forward scan settles on the wrong occurrence and then drags past everything after it. A WordAttempt now carries the same Option. The settle paths know where the word started and pass it; the attempts the contraction engine builds in its own buffer keep None and keep the search, which is correct for them -- they are candidates, and most are discarded. rebase_attributions moves word offsets along with the others, so the cascade added for nemeth prose chunks covers them as well. Spreadsheet: unattributed 233 -> 4, rows 4 -> 2, 857685/857689 (99.9995%). Braille output identical on all 5157 rows. Suite 5274 + 20 + 8 + 351 + 162. The four that remain are one cell each: the letter of a single-capital chemical symbol, whose attempt comes from the contraction engine as a one-cell run with no position. Placing those means giving the engine's own attempts a position relative to the word, which is a larger change than this one. --- libs/braillify/src/rules/english_ueb/mod.rs | 36 ++++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/libs/braillify/src/rules/english_ueb/mod.rs b/libs/braillify/src/rules/english_ueb/mod.rs index 70f46a17..a5733104 100644 --- a/libs/braillify/src/rules/english_ueb/mod.rs +++ b/libs/braillify/src/rules/english_ueb/mod.rs @@ -74,6 +74,11 @@ enum AttributionRecord { struct WordAttempt { cells: Vec, moves: Vec<(crate::rules::trace::RuleId, u32, u32)>, + /// Where the cells were written, for an attempt that went straight into a + /// buffer rather than being one of several the engine chose between. The + /// literal `` markup of a chemical line repeats the same two-cell run + /// a dozen times, and a search cannot tell those occurrences apart. + offset: Option, } struct NonWordAttempt { @@ -114,6 +119,10 @@ impl AttemptRecorder { } pub(super) fn finish(self, cells: &[u8]) { + self.finish_at(cells, None); + } + + pub(super) fn finish_at(self, cells: &[u8], offset: Option) { let Some(moves) = self.moves else { return; }; @@ -124,6 +133,7 @@ impl AttemptRecorder { records.push(AttributionRecord::Word(WordAttempt { cells: cells.to_vec(), moves, + offset, })); } }); @@ -184,11 +194,11 @@ fn rebase_attributions(checkpoint: usize, base: usize) { && let Some(records) = slot.as_mut() { for record in records.iter_mut().skip(checkpoint) { - let attempt = match record { - AttributionRecord::Word(_) => continue, - AttributionRecord::Indicator(a) | AttributionRecord::Direct(a) => a, + let offset = match record { + AttributionRecord::Word(w) => &mut w.offset, + AttributionRecord::Indicator(a) | AttributionRecord::Direct(a) => &mut a.offset, }; - if let Some(offset) = attempt.offset.as_mut() { + if let Some(offset) = offset.as_mut() { *offset += base; } } @@ -242,7 +252,12 @@ fn align_selected(cells: &[u8], records: &[AttributionRecord]) -> Vec { AttributionRecord::Word(attempt) => attempt, AttributionRecord::Indicator(_) | AttributionRecord::Direct(_) => continue, }; - let Some(base) = find_from_outside(cells, &attempt.cells, cursor, &direct_spans) else { + let placed = attempt.offset.filter(|offset| { + cells.get(*offset..offset + attempt.cells.len()) == Some(attempt.cells.as_slice()) + }); + let Some(base) = + placed.or_else(|| find_from_outside(cells, &attempt.cells, cursor, &direct_spans)) + else { continue; }; for (rule, offset, len) in &attempt.moves { @@ -403,7 +418,7 @@ pub(super) fn settle_word_attribution(pending: Option, out: &[u8]) return; }; if attempt_count() == attempts_before && out.len() > start { - record_whole_word(UebMoveSource::Letter, &out[start..]); + record_whole_word_at(UebMoveSource::Letter, &out[start..], Some(start)); } } @@ -411,18 +426,22 @@ pub(super) fn settle_symbol_attribution(start: Option, out: &[u8]) { if let Some(start) = start && out.len() > start { - record_whole_word(UebMoveSource::Symbol, &out[start..]); + record_whole_word_at(UebMoveSource::Symbol, &out[start..], Some(start)); } } pub(super) fn record_whole_word(source: UebMoveSource, cells: &[u8]) { + record_whole_word_at(source, cells, None); +} + +pub(super) fn record_whole_word_at(source: UebMoveSource, cells: &[u8], offset: Option) { let mut attempt = AttemptRecorder::new(); attempt.push( crate::rules::trace::RuleId::ueb(source as usize), 0, cells.len(), ); - attempt.finish(cells); + attempt.finish_at(cells, offset); } pub(super) fn push_indicator(out: &mut Vec, source: UebMoveSource, cells: &[u8]) { @@ -1294,6 +1313,7 @@ mod encode_pipeline_tests { #[case::camel_subunit_word("aMgO")] #[case::camel_caps_word("dCO")] #[case::balanced_equation("aMgO(s)$+$bC(s)→cMg(s)$+$dCO(g)$+$eCO2(g)")] + #[case::repeated_subscript_markup("CO2, SO2, CO2")] fn every_cell_of_a_chemical_line_names_a_rule(#[case] input: &str) { let (cells, trace) = crate::encode_with_trace(input).expect("input must encode"); let untraced = crate::encode(input).expect("input must encode untraced"); From 22261dc983b4268e1690163eed84ba0cc4fd34fa Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 00:30:36 +0900 Subject: [PATCH 042/132] Add the science fixtures for chemical formulas and reaction equations The chemistry articles were the one part of the standard with no fixtures at all, so there was no way to say what the encoder does or does not handle. The symbol definitions are unambiguous in the PDF -- each sits alone on its line with its internal notation beside it -- so they go in first, which is the order the fixture rules ask for anyway. Converting the PDF's internal notation was checked against the 2339 existing korean and math entries first: every one of them round-trips to the expected and unicode already recorded, so the same converter can be trusted here. Both groups are marked benchmark, like the corpora, because the chemistry engine does not exist yet and a regulation fixture that cannot pass would otherwise fail the gate. Running them says exactly where the work is. Article 18's reaction symbols already encode correctly, 6 of 6 -- the plus sign, the arrows, the reversible sign, gas and precipitate. Article 7's formulas are 0 of 4: the capital indicator before an element symbol and the subscript after it are what is missing. testcase integrity 14171 passing, suite 5274 + 20 + 8 + 351 + 162. --- rule_map.json | 10 ++++++ test_cases/science/science_18.json | 56 ++++++++++++++++++++++++++++++ test_cases/science/science_7.json | 38 ++++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 test_cases/science/science_18.json create mode 100644 test_cases/science/science_7.json diff --git a/rule_map.json b/rule_map.json index 4b132b05..c3a7c56b 100644 --- a/rule_map.json +++ b/rule_map.json @@ -1800,5 +1800,15 @@ "description": "NIKL Korean-Korean Braille Parallel Corpus 2025 v1.0", "benchmark": true, "shards": true + }, + "science/science_7": { + "title": "과학 제7항", + "description": "화학식은 원소 기호 앞에 대문자 기호표를 적고, 아래 첨자는 ⠰ 뒤에 내용을 적는다.", + "benchmark": true + }, + "science/science_18": { + "title": "과학 제18항", + "description": "화학 반응식의 기호는 앞뒤를 한 칸씩 띄어 쓰고, 기체 발생과 침전 기호는 분자식에 붙여 적는다.", + "benchmark": true } } diff --git a/test_cases/science/science_18.json b/test_cases/science/science_18.json new file mode 100644 index 00000000..59095378 --- /dev/null +++ b/test_cases/science/science_18.json @@ -0,0 +1,56 @@ +[ + { + "input": "+", + "note": "PDF 과학 제18항 1. 반응식 더하기 기호 정의", + "internal": "5", + "expected": "34", + "unicode": "⠢", + "world": "", + "jeomsarang": "" + }, + { + "input": "→", + "note": "PDF 과학 제18항 1. 오른쪽 화살표 정의", + "internal": "3o", + "expected": "1821", + "unicode": "⠒⠕", + "world": "", + "jeomsarang": "" + }, + { + "input": "←", + "note": "PDF 과학 제18항 1. 왼쪽 화살표 정의", + "internal": "{3", + "expected": "4218", + "unicode": "⠪⠒", + "world": "", + "jeomsarang": "" + }, + { + "input": "⇄", + "note": "PDF 과학 제18항 1. 가역 반응 기호 정의", + "internal": "[7o", + "expected": "425421", + "unicode": "⠪⠶⠕", + "world": "", + "jeomsarang": "" + }, + { + "input": "↑", + "note": "PDF 과학 제18항 2. 기체 발생 기호 정의", + "internal": ";3o", + "expected": "481821", + "unicode": "⠰⠒⠕", + "world": "", + "jeomsarang": "" + }, + { + "input": "↓", + "note": "PDF 과학 제18항 2. 침전 기호 정의", + "internal": "^3o", + "expected": "241821", + "unicode": "⠘⠒⠕", + "world": "", + "jeomsarang": "" + } +] diff --git a/test_cases/science/science_7.json b/test_cases/science/science_7.json new file mode 100644 index 00000000..8abe26df --- /dev/null +++ b/test_cases/science/science_7.json @@ -0,0 +1,38 @@ +[ + { + "input": ";", + "note": "PDF 과학 제7항 3. 아래 첨자 기호 정의", + "internal": ";", + "expected": "48", + "unicode": "⠰", + "world": "", + "jeomsarang": "" + }, + { + "input": "O2", + "note": "PDF 과학 제7항 3. 아래 첨자 예제", + "internal": ",o;#b", + "expected": "322148603", + "unicode": "⠠⠕⠰⠼⠃", + "world": "", + "jeomsarang": "" + }, + { + "input": "Ca(OH)2", + "note": "PDF 과학 제7항 4. 괄호는 수학 제6항에 따른다", + "internal": ",ca8,o,h0;#b", + "expected": "329138322132195248603", + "unicode": "⠠⠉⠁⠦⠠⠕⠠⠓⠴⠰⠼⠃", + "world": "", + "jeomsarang": "" + }, + { + "input": "[Cu(NH3)4](OH)2", + "note": "PDF 과학 제7항 4. 대괄호 예제", + "internal": "(',cu8,n,h;#c0;#d,)8,o,h0;#b", + "expected": "5543293738322932194860952486025326238322132195248603", + "unicode": "⠷⠄⠠⠉⠥⠦⠠⠝⠠⠓⠰⠼⠉⠴⠰⠼⠙⠠⠾⠦⠠⠕⠠⠓⠴⠰⠼⠃", + "world": "", + "jeomsarang": "" + } +] From c222a6d740b3bb6067b15077a71e426942f1c485 Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 00:41:11 +0900 Subject: [PATCH 043/132] Add the rest of the science fixtures and map what already works The chemistry articles now have fixtures for every symbol the PDF defines with both its print form and its internal notation beside it: the mass-number superscript of article 3, the bond lines of article 10, the ring cores of article 12, the electrode and salt-bridge marks of article 21, and the sex signs of article 22. Entries whose print form the PDF only shows as a diagram are left out rather than guessed at. The integrity check now covers the science directory too, which is what checks internal against expected and unicode -- 14301 assertions passing, up from 14171. It deliberately skips runConversionTests, since that asks the encoder for output the chemistry engine cannot produce yet. With all of them registered the picture is no longer a guess: article 18 reaction symbols 6/6 article 21 electrode marks 1/2 articles 3, 7, 10, 12, 22 0 science overall 7/26 So the reaction symbols are already right and need nothing. What is missing is the formula notation itself -- the capital indicator before an element symbol, the subscript and superscript after it, and the bond lines built on the same subscript prefix. Suite 5274 + 20 + 8 + 351 + 162, testcase integrity 14301. --- rule_map.json | 25 ++++++++++++ test_cases/science/science_10.json | 56 +++++++++++++++++++++++++++ test_cases/science/science_12.json | 29 ++++++++++++++ test_cases/science/science_21.json | 20 ++++++++++ test_cases/science/science_22.json | 20 ++++++++++ test_cases/science/science_3.json | 29 ++++++++++++++ test_cases/testcase-integrity.test.ts | 4 ++ 7 files changed, 183 insertions(+) create mode 100644 test_cases/science/science_10.json create mode 100644 test_cases/science/science_12.json create mode 100644 test_cases/science/science_21.json create mode 100644 test_cases/science/science_22.json create mode 100644 test_cases/science/science_3.json diff --git a/rule_map.json b/rule_map.json index c3a7c56b..b92ffe76 100644 --- a/rule_map.json +++ b/rule_map.json @@ -1810,5 +1810,30 @@ "title": "과학 제18항", "description": "화학 반응식의 기호는 앞뒤를 한 칸씩 띄어 쓰고, 기체 발생과 침전 기호는 분자식에 붙여 적는다.", "benchmark": true + }, + "science/science_3": { + "title": "과학 제3항", + "description": "원소 기호를 먼저 적고 원자 번호는 아래 첨자로, 질량수는 위 첨자로 적는다.", + "benchmark": true + }, + "science/science_10": { + "title": "과학 제10항", + "description": "사슬 화합물의 결합선은 ⠰을 먼저 적고 결합 수에 따라 단일 1, 이중 2, 삼중 3을 붙여 적는다.", + "benchmark": true + }, + "science/science_12": { + "title": "과학 제12항", + "description": "고리 화합물의 육각 환핵은 세로 방향과 가로 방향을, 오각 환핵은 별도의 기호를 적는다.", + "benchmark": true + }, + "science/science_21": { + "title": "과학 제21항", + "description": "전지의 전극 표시와 염다리 표시는 앞뒤를 한 칸씩 띄어 적는다.", + "benchmark": true + }, + "science/science_22": { + "title": "과학 제22항", + "description": "여성 기호와 남성 기호를 적는 방법을 정한다.", + "benchmark": true } } diff --git a/test_cases/science/science_10.json b/test_cases/science/science_10.json new file mode 100644 index 00000000..51373c7e --- /dev/null +++ b/test_cases/science/science_10.json @@ -0,0 +1,56 @@ +[ + { + "input": "-", + "note": "PDF 과학 제10항 2. 단일 결합선 정의", + "internal": ";1", + "expected": "482", + "unicode": "⠰⠂", + "world": "", + "jeomsarang": "" + }, + { + "input": "=", + "note": "PDF 과학 제10항 2. 이중 결합선 정의", + "internal": ";2", + "expected": "486", + "unicode": "⠰⠆", + "world": "", + "jeomsarang": "" + }, + { + "input": "≡", + "note": "PDF 과학 제10항 2. 삼중 결합선 정의", + "internal": ";3", + "expected": "4818", + "unicode": "⠰⠒", + "world": "", + "jeomsarang": "" + }, + { + "input": "H-O-H", + "note": "PDF 과학 제10항 2. 단일 결합 예제", + "internal": ",,,h;1o;1h,'", + "expected": "323232194822148219324", + "unicode": "⠠⠠⠠⠓⠰⠂⠕⠰⠂⠓⠠⠄", + "world": "", + "jeomsarang": "" + }, + { + "input": "O=C=O", + "note": "PDF 과학 제10항 2. 이중 결합 예제", + "internal": ",,,o;2c;2o,'", + "expected": "32323221486948621324", + "unicode": "⠠⠠⠠⠕⠰⠆⠉⠰⠆⠕⠠⠄", + "world": "", + "jeomsarang": "" + }, + { + "input": "H-C≡C-H", + "note": "PDF 과학 제10항 2. 삼중 결합 예제", + "internal": ",,,h;1c;3c;1h,'", + "expected": "3232321948294818948219324", + "unicode": "⠠⠠⠠⠓⠰⠂⠉⠰⠒⠉⠰⠂⠓⠠⠄", + "world": "", + "jeomsarang": "" + } +] diff --git a/test_cases/science/science_12.json b/test_cases/science/science_12.json new file mode 100644 index 00000000..f32eb898 --- /dev/null +++ b/test_cases/science/science_12.json @@ -0,0 +1,29 @@ +[ + { + "input": "⬡", + "note": "PDF 과학 제12항 1. 육각 환핵 세로 방향 정의", + "internal": "&{oy", + "expected": "47422161", + "unicode": "⠯⠪⠕⠽", + "world": "", + "jeomsarang": "" + }, + { + "input": "⬢", + "note": "PDF 과학 제12항 1. 육각 환핵 가로 방향 정의", + "internal": "&o{y", + "expected": "47214261", + "unicode": "⠯⠕⠪⠽", + "world": "", + "jeomsarang": "" + }, + { + "input": "⬠", + "note": "PDF 과학 제12항 2. 오각 환핵 정의", + "internal": "&{ky", + "expected": "4742561", + "unicode": "⠯⠪⠅⠽", + "world": "", + "jeomsarang": "" + } +] diff --git a/test_cases/science/science_21.json b/test_cases/science/science_21.json new file mode 100644 index 00000000..859a6a6f --- /dev/null +++ b/test_cases/science/science_21.json @@ -0,0 +1,20 @@ +[ + { + "input": "∣", + "note": "PDF 과학 제21항 전극 표시 정의", + "internal": "|", + "expected": "51", + "unicode": "⠳", + "world": "", + "jeomsarang": "" + }, + { + "input": "∥", + "note": "PDF 과학 제21항 염다리 표시 정의", + "internal": "||", + "expected": "5151", + "unicode": "⠳⠳", + "world": "", + "jeomsarang": "" + } +] diff --git a/test_cases/science/science_22.json b/test_cases/science/science_22.json new file mode 100644 index 00000000..b369aa56 --- /dev/null +++ b/test_cases/science/science_22.json @@ -0,0 +1,20 @@ +[ + { + "input": "♀", + "note": "PDF 과학 제22항 여성 기호 정의", + "internal": "0^x4", + "expected": "52244550", + "unicode": "⠴⠘⠭⠲", + "world": "", + "jeomsarang": "" + }, + { + "input": "♂", + "note": "PDF 과학 제22항 남성 기호 정의", + "internal": "0^y4", + "expected": "52246150", + "unicode": "⠴⠘⠽⠲", + "world": "", + "jeomsarang": "" + } +] diff --git a/test_cases/science/science_3.json b/test_cases/science/science_3.json new file mode 100644 index 00000000..3521e766 --- /dev/null +++ b/test_cases/science/science_3.json @@ -0,0 +1,29 @@ +[ + { + "input": "~", + "note": "PDF 과학 제3항 위 첨자(질량수) 기호 정의", + "internal": "~", + "expected": "24", + "unicode": "⠘", + "world": "", + "jeomsarang": "" + }, + { + "input": "7Li", + "note": "PDF 과학 제3항 질량수 예제", + "internal": ",li~#g", + "expected": "32710246027", + "unicode": "⠠⠇⠊⠘⠼⠛", + "world": "", + "jeomsarang": "" + }, + { + "input": "8O", + "note": "PDF 과학 제3항 원자 번호 예제", + "internal": ",o;#h", + "expected": "3221486019", + "unicode": "⠠⠕⠰⠼⠓", + "world": "", + "jeomsarang": "" + } +] diff --git a/test_cases/testcase-integrity.test.ts b/test_cases/testcase-integrity.test.ts index d4aee09e..bbc82d10 100644 --- a/test_cases/testcase-integrity.test.ts +++ b/test_cases/testcase-integrity.test.ts @@ -215,6 +215,10 @@ function runShardedIntegrityTests(dir: string, label: string) { runIntegrityTests('korean', 'Korean') runIntegrityTests('math', 'Math') +// The chemistry engine is not written yet, so these fixtures are checked for +// internal consistency only — `runConversionTests` would ask the encoder to +// produce output it cannot produce. +runIntegrityTests('science', 'Science') runShardedIntegrityTests('2024_corpus', 'NIKL 2024 corpus') runShardedIntegrityTests('2025_corpus', 'NIKL 2025 corpus') runConversionTests('korean', 'Korean') From e65011d210e100bd42f14e4acef877ea49e99017 Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 00:59:14 +0900 Subject: [PATCH 044/132] Correct the science fixture inputs the PDF shows as subscripts The PDF writes atomic numbers, mass numbers and formula counts as real subscripts and superscripts. Text extraction flattens them to ordinary digits, and I copied that flattening straight into the fixture inputs last commit, so O2 stood where the standard means O2 with a subscript two. The braille says which it is. Article 3 lists the atomic number eight as ,o;#h -- the element first, the subscript after -- which only makes sense if the print form carries the eight ahead of the symbol as a subscript. The internal notation, expected and unicode all stay exactly as the PDF has them; only the input is corrected. This also settles how the formulas can be recognised at all. Measured against the corpus: a bare letter-plus-digit matches 9589 sentences, and even after keeping only real element symbols it still matches 5318 -- F1, P100, V6, S3, none of them chemistry. An element symbol followed by a unicode subscript or superscript matches 4, two of which are chemistry. That is the signal. testcase integrity 14301 passing, science 7/26 unchanged. --- test_cases/science/science_3.json | 4 ++-- test_cases/science/science_7.json | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test_cases/science/science_3.json b/test_cases/science/science_3.json index 3521e766..4e169d21 100644 --- a/test_cases/science/science_3.json +++ b/test_cases/science/science_3.json @@ -9,7 +9,7 @@ "jeomsarang": "" }, { - "input": "7Li", + "input": "⁷Li", "note": "PDF 과학 제3항 질량수 예제", "internal": ",li~#g", "expected": "32710246027", @@ -18,7 +18,7 @@ "jeomsarang": "" }, { - "input": "8O", + "input": "₈O", "note": "PDF 과학 제3항 원자 번호 예제", "internal": ",o;#h", "expected": "3221486019", diff --git a/test_cases/science/science_7.json b/test_cases/science/science_7.json index 8abe26df..a2ce44cd 100644 --- a/test_cases/science/science_7.json +++ b/test_cases/science/science_7.json @@ -9,7 +9,7 @@ "jeomsarang": "" }, { - "input": "O2", + "input": "O₂", "note": "PDF 과학 제7항 3. 아래 첨자 예제", "internal": ",o;#b", "expected": "322148603", @@ -18,7 +18,7 @@ "jeomsarang": "" }, { - "input": "Ca(OH)2", + "input": "Ca(OH)₂", "note": "PDF 과학 제7항 4. 괄호는 수학 제6항에 따른다", "internal": ",ca8,o,h0;#b", "expected": "329138322132195248603", @@ -27,7 +27,7 @@ "jeomsarang": "" }, { - "input": "[Cu(NH3)4](OH)2", + "input": "[Cu(NH₃)₄](OH)₂", "note": "PDF 과학 제7항 4. 대괄호 예제", "internal": "(',cu8,n,h;#c0;#d,)8,o,h0;#b", "expected": "5543293738322932194860952486025326238322132195248603", From 733df6c672e2e8232011e85c18c1bc1752542f74 Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 01:39:14 +0900 Subject: [PATCH 045/132] Add the sex signs of article 22 and drop the fixtures I invented The female and male signs were rejected outright -- Invalid symbol character -- so adding them to the Korean symbol table is purely additive. Article 22 gives them as 0^x4 and 0^y4, and they now encode as that both alone and inside a Korean sentence. Three of the fixtures I wrote last commit had no basis and are removed. The bond lines of article 10 were entered as bare hyphen, equals and identical-to, but those characters already mean hyphen, equals and identical-to in ordinary text -- a bond line only exists inside a structural formula, so it has no standalone print form to test. The ring cores of article 12 are shown in the PDF as diagrams, and the hexagon characters I picked for them were a guess. The three structural examples of article 10 stay, since the PDF prints those. Article 21's salt bridge is left as it is and still fails: the same character is parallel under maths article 44, which is what the encoder produces. That is a context question, not a missing symbol. science 9/20, with article 18 at 6/6 and article 22 now 2/2. Corpus unchanged at 456025/467121. Suite 5274 + 20 + 8 + 351 + 162, testcase integrity 14271. --- libs/braillify/src/symbol_shortcut.rs | 3 +++ rule_map.json | 5 ----- test_cases/science/science_10.json | 27 ------------------------- test_cases/science/science_12.json | 29 --------------------------- 4 files changed, 3 insertions(+), 61 deletions(-) delete mode 100644 test_cases/science/science_12.json diff --git a/libs/braillify/src/symbol_shortcut.rs b/libs/braillify/src/symbol_shortcut.rs index 6e1a0023..67bf3e44 100644 --- a/libs/braillify/src/symbol_shortcut.rs +++ b/libs/braillify/src/symbol_shortcut.rs @@ -16,6 +16,9 @@ static SHORTCUT_MAP: phf::Map = phf_map! { // 제53항 [다만] — 점 개수를 밝혀야 하는 줄임표는 묵자의 점 수만큼 // ⠠을 적는다. U+2025 TWO DOT LEADER visibly carries two points. '‥' => &[decode_unicode('⠠'), decode_unicode('⠠')], + // 과학 제22항 — 여성 기호 ♀는 0^x4로, 남성 기호 ♂은 0^y4로 적는다. + '♀' => &[decode_unicode('⠴'), decode_unicode('⠘'), decode_unicode('⠭'), decode_unicode('⠲')], + '♂' => &[decode_unicode('⠴'), decode_unicode('⠘'), decode_unicode('⠽'), decode_unicode('⠲')], '!' => &[decode_unicode('⠖')], '.' => &[decode_unicode('⠲')], ',' => &[decode_unicode('⠐')], diff --git a/rule_map.json b/rule_map.json index b92ffe76..77eeea0b 100644 --- a/rule_map.json +++ b/rule_map.json @@ -1821,11 +1821,6 @@ "description": "사슬 화합물의 결합선은 ⠰을 먼저 적고 결합 수에 따라 단일 1, 이중 2, 삼중 3을 붙여 적는다.", "benchmark": true }, - "science/science_12": { - "title": "과학 제12항", - "description": "고리 화합물의 육각 환핵은 세로 방향과 가로 방향을, 오각 환핵은 별도의 기호를 적는다.", - "benchmark": true - }, "science/science_21": { "title": "과학 제21항", "description": "전지의 전극 표시와 염다리 표시는 앞뒤를 한 칸씩 띄어 적는다.", diff --git a/test_cases/science/science_10.json b/test_cases/science/science_10.json index 51373c7e..a2f12444 100644 --- a/test_cases/science/science_10.json +++ b/test_cases/science/science_10.json @@ -1,31 +1,4 @@ [ - { - "input": "-", - "note": "PDF 과학 제10항 2. 단일 결합선 정의", - "internal": ";1", - "expected": "482", - "unicode": "⠰⠂", - "world": "", - "jeomsarang": "" - }, - { - "input": "=", - "note": "PDF 과학 제10항 2. 이중 결합선 정의", - "internal": ";2", - "expected": "486", - "unicode": "⠰⠆", - "world": "", - "jeomsarang": "" - }, - { - "input": "≡", - "note": "PDF 과학 제10항 2. 삼중 결합선 정의", - "internal": ";3", - "expected": "4818", - "unicode": "⠰⠒", - "world": "", - "jeomsarang": "" - }, { "input": "H-O-H", "note": "PDF 과학 제10항 2. 단일 결합 예제", diff --git a/test_cases/science/science_12.json b/test_cases/science/science_12.json deleted file mode 100644 index f32eb898..00000000 --- a/test_cases/science/science_12.json +++ /dev/null @@ -1,29 +0,0 @@ -[ - { - "input": "⬡", - "note": "PDF 과학 제12항 1. 육각 환핵 세로 방향 정의", - "internal": "&{oy", - "expected": "47422161", - "unicode": "⠯⠪⠕⠽", - "world": "", - "jeomsarang": "" - }, - { - "input": "⬢", - "note": "PDF 과학 제12항 1. 육각 환핵 가로 방향 정의", - "internal": "&o{y", - "expected": "47214261", - "unicode": "⠯⠕⠪⠽", - "world": "", - "jeomsarang": "" - }, - { - "input": "⬠", - "note": "PDF 과학 제12항 2. 오각 환핵 정의", - "internal": "&{ky", - "expected": "4742561", - "unicode": "⠯⠪⠅⠽", - "world": "", - "jeomsarang": "" - } -] From 9e779ae202965e014ab0453db78f951762ec35ec Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 01:49:14 +0900 Subject: [PATCH 046/132] Spell a formula's element symbols letter by letter Maths article 12 runs consecutive capitals together under one caps-word sign, which is right for AB and wrong for a chemical formula: science article 4 says the element symbols are all written in grade 1, each with its own capital sign. Ca(OH)2 came out as the caps-word form and so did NH inside [Cu(NH3)4](OH)2. The branch that already spells a matrix name out letter by letter now also takes formulas. What counts as one is narrow on purpose. Single-letter element symbols are the same letters maths uses for variables -- P, V, B, C -- so the sequence only qualifies when the expression carries a subscript, which is the signal measured earlier: an element symbol followed by a unicode subscript occurs in 4 corpus sentences out of 467121, against 5318 for a bare letter and digit. Ca(OH)2 and [Cu(NH3)4](OH)2 now match the standard, taking article 7 from 0 of 4 to 2 of 4 and science to 11 of 20. Corpus unchanged at 456025/467121. Suite 5274 + 20 + 8 + 351 + 162, fmt and clippy clean. --- libs/braillify/src/rules/math/rule_12.rs | 41 +++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/libs/braillify/src/rules/math/rule_12.rs b/libs/braillify/src/rules/math/rule_12.rs index abbd25dc..d1466df7 100644 --- a/libs/braillify/src/rules/math/rule_12.rs +++ b/libs/braillify/src/rules/math/rule_12.rs @@ -246,6 +246,44 @@ pub fn encode_upper_variable( } } + /// 과학 제4항 — 화학식의 원소 기호는 모두 1급 점자로 적는다. 수학 제12항의 + /// 대문자 이어쓰기(`⠠⠠`)가 아니라 글자마다 대문자표를 붙인다. + /// + /// 아래 첨자가 있는 식으로 한정한다. 한 글자짜리 원소 기호는 수학 변수와 + /// 글자가 겹치므로(`P`, `V`, `B`, `C`), 첨자라는 화학식 신호가 없으면 + /// 행렬 아닌 대문자 변수까지 갈라놓게 된다. + fn names_element_symbols(tokens: &[MathToken], start: usize, end: usize) -> bool { + if !tokens.iter().any(|t| matches!(t, MathToken::Subscript(_))) { + return false; + } + let letters: Vec = tokens[start..end] + .iter() + .filter_map(|token| match token { + MathToken::UpperVariable(letter) => Some(*letter), + _ => None, + }) + .collect(); + letters.len() >= 2 + && letters.iter().all(|letter| { + matches!( + letter, + 'H' | 'B' + | 'C' + | 'N' + | 'O' + | 'F' + | 'P' + | 'S' + | 'K' + | 'V' + | 'Y' + | 'I' + | 'W' + | 'U' + ) + }) + } + let mut seq_end = *i; let mut uppercase_count = 0usize; while let Some(MathToken::UpperVariable(_)) = tokens.get(seq_end) { @@ -259,7 +297,8 @@ pub fn encode_upper_variable( // PDF 제12항 붙임 1 — 행렬 컨텍스트면 2-cap 행렬명(`AB`)을 ⠠+letter 개별 표기. // The seq_end loop above guarantees tokens[*i..seq_end] contains only // UpperVariable and Prime tokens (no other arms reachable). - if uppercase_count == 2 && matrix_context_active { + if (uppercase_count == 2 && matrix_context_active) || names_element_symbols(tokens, *i, seq_end) + { for token in &tokens[*i..seq_end] { if let MathToken::UpperVariable(upper) = token { result.push(32); From 1e295bfa57eb0dc56ceae574043bf80d8e216475 Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 02:39:23 +0900 Subject: [PATCH 047/132] Put the roman indicator back on a subscripted formula I had O2 down as needing no roman indicator and the encoder as being inconsistent for emitting one. The standard says the opposite. Korean article 68, which is the article that defines how subscripts are written at all, lists its examples with the indicator attached -- B6 as 0,b;#f, and the square metre as 0m^#b. The encoder produces exactly that. What I had copied was science article 7's definition line, which shows only the formula's own cells because that is all a definition line shows. A fixture whose input is the formula runs the whole pipeline, so it gets the indicator the same way B6 does. The subscript sign also loses its standalone entry. Its print form is a semicolon, which already means semicolon -- the same mistake as the bond lines removed earlier. Article 7 now passes 3 of 3 and science stands at 12 of 19. Corpus unchanged at 456025/467121, testcase integrity 14266. --- test_cases/science/science_7.json | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/test_cases/science/science_7.json b/test_cases/science/science_7.json index a2ce44cd..dc192e7f 100644 --- a/test_cases/science/science_7.json +++ b/test_cases/science/science_7.json @@ -1,19 +1,10 @@ [ - { - "input": ";", - "note": "PDF 과학 제7항 3. 아래 첨자 기호 정의", - "internal": ";", - "expected": "48", - "unicode": "⠰", - "world": "", - "jeomsarang": "" - }, { "input": "O₂", - "note": "PDF 과학 제7항 3. 아래 첨자 예제", - "internal": ",o;#b", - "expected": "322148603", - "unicode": "⠠⠕⠰⠼⠃", + "note": "PDF 한글 제68항 — 아래 첨자는 ; 뒤에 내용. 로마자표 0 포함 (B₆ 0,b;#f 와 같은 형태)", + "internal": "0,o;#b", + "expected": "52322148603", + "unicode": "⠴⠠⠕⠰⠼⠃", "world": "", "jeomsarang": "" }, From 6c09b0bf1747176db1f9af84201a41b2d9fd84d9 Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 02:48:29 +0900 Subject: [PATCH 048/132] Write the element before its atomic number and mass number Science article 3 puts the element symbol first and the numbers after it as subscript and superscript. Maths articles 18 and 19 do the opposite for a left superscript or subscript: the index stays in front and is wrapped in a group. Both are right for their own notation, so 7Li came out as the maths form with the seven leading and parenthesised. The superscript and subscript rules now hand the following tokens to a shared helper that writes an element symbol with its capital sign and reports how many tokens it took. When it reports nothing the maths path runs unchanged, which is what keeps a left superscript on an ordinary variable intact -- n over x still groups the way article 18 asks. The helper checks against a list of real element symbols rather than any capital letter, because a one-letter symbol is the same letter maths uses for a variable. Article 3 now passes 2 of 2 and science stands at 14 of 18. Corpus unchanged at 456025/467121. Suite 5274 + 20 + 8 + 351 + 162, fmt and clippy clean. --- libs/braillify/src/rules/math/rule_18.rs | 50 ++++++++++++++++++++++++ libs/braillify/src/rules/math/rule_19.rs | 11 ++++++ test_cases/science/science_3.json | 9 ----- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/libs/braillify/src/rules/math/rule_18.rs b/libs/braillify/src/rules/math/rule_18.rs index 92cf54fc..4c6747de 100644 --- a/libs/braillify/src/rules/math/rule_18.rs +++ b/libs/braillify/src/rules/math/rule_18.rs @@ -31,6 +31,47 @@ fn next_non_space(tokens: &[MathToken], mut idx: usize) -> Option<&MathToken> { /// PDF 수학 제18항 2 — 좌상첨자: 위첨자가 변수 앞에 단독 위치할 때. /// 앞에 피첨자(변수/숫자/괄호닫기)가 없고 뒤에 변수가 이어지면 좌상첨자다. /// 단, 합/적분/극한 등 한정자 뒤의 첨자(예: ∑_{k=0}^{∞} 의 ^∞)는 좌상첨자가 아니다. +/// 원소 기호를 대문자표와 함께 emit하고 소비한 토큰 수를 돌려준다. +/// +/// 원소 기호가 아니면 `None`을 돌려주고 아무것도 쓰지 않는다. 한 글자짜리 +/// 원소 기호는 수학 변수와 글자가 겹치므로, 실제 원소 기호 목록에 있는 것만 +/// 통과시켜 좌상첨자가 붙은 일반 변수(`ⁿx`)를 건드리지 않는다. +pub(super) fn emit_element_symbol( + tokens: &[MathToken], + index: usize, + result: &mut Vec, +) -> Result, String> { + const SYMBOLS: &[&str] = &[ + "H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne", "Na", "Mg", "Al", "Si", "P", "S", + "Cl", "Ar", "K", "Ca", "Fe", "Co", "Ni", "Cu", "Zn", "Br", "Ag", "Sn", "I", "Ba", "Pt", + "Au", "Hg", "Pb", "U", + ]; + let Some(MathToken::UpperVariable(upper)) = tokens.get(index) else { + return Ok(None); + }; + let lower = match tokens.get(index + 1) { + Some(MathToken::Variable(letter)) if letter.is_ascii_lowercase() => Some(*letter), + _ => None, + }; + let two: Option = lower.map(|letter| format!("{upper}{letter}")); + let (symbol, consumed) = match two { + Some(ref name) if SYMBOLS.contains(&name.as_str()) => (name.as_str(), 2), + _ if SYMBOLS.contains(&upper.to_string().as_str()) => return single(*upper, result), + _ => return Ok(None), + }; + result.push(32); + for letter in symbol.chars() { + result.push(crate::english::encode_english(letter.to_ascii_lowercase())?); + } + Ok(Some(consumed)) +} + +fn single(upper: char, result: &mut Vec) -> Result, String> { + result.push(32); + result.push(crate::english::encode_english(upper.to_ascii_lowercase())?); + Ok(Some(1)) +} + fn is_left_superscript_position(tokens: &[MathToken], index: usize) -> bool { let prev_blocks = matches!( prev_non_space(tokens, index), @@ -250,6 +291,15 @@ pub fn encode_superscript( // 좌상첨자는 단일 토큰이라도 그룹 괄호로 묶는다. let is_left_superscript = is_left_superscript_position(tokens, *i); + // 과학 제3항 — 원소 기호를 먼저 적고 질량수를 위 첨자로 적는다(⁷Li → ,li~#g). + // 수학 제18항 2의 좌상첨자는 제자리에 괄호로 묶이지만 동위원소는 원소가 앞선다. + if is_left_superscript && let Some(consumed) = emit_element_symbol(tokens, *i + 1, result)? { + result.push(24); + engine.encode_tokens(sup_content, result)?; + *i += 1 + consumed; + return Ok(false); + } + result.push(24); if wrapped_simple_index { // 본문 그대로 emit하여 ⠦⠴(MathParen) 보존. diff --git a/libs/braillify/src/rules/math/rule_19.rs b/libs/braillify/src/rules/math/rule_19.rs index 63fb1959..7ae8262a 100644 --- a/libs/braillify/src/rules/math/rule_19.rs +++ b/libs/braillify/src/rules/math/rule_19.rs @@ -169,6 +169,17 @@ pub fn encode_subscript( return Ok(false); } + // 과학 제3항 — 원소 기호를 먼저 적고 원자 번호를 아래 첨자로 적는다(₈O → ,o;#h). + // 수학 제19항 2의 좌하첨자는 제자리에 묶이지만 원자 번호는 원소가 앞선다. + if is_left_subscript_position(tokens, *i) + && let Some(consumed) = super::rule_18::emit_element_symbol(tokens, *i + 1, result)? + { + result.push(48); + engine.encode_tokens(content, result)?; + *i += 1 + consumed; + return Ok(false); + } + result.push(48); // 적분/합/곱(∫ ∑ ∏ 등) 한정자 뒤 첨자는 묶음 없이 본문 그대로 출력한다. // PDF 제51항 [붙임] — `\substack`로 펼쳐진 두 번째 이상 첨자도 동일한 한정자 diff --git a/test_cases/science/science_3.json b/test_cases/science/science_3.json index 4e169d21..6e011c21 100644 --- a/test_cases/science/science_3.json +++ b/test_cases/science/science_3.json @@ -1,13 +1,4 @@ [ - { - "input": "~", - "note": "PDF 과학 제3항 위 첨자(질량수) 기호 정의", - "internal": "~", - "expected": "24", - "unicode": "⠘", - "world": "", - "jeomsarang": "" - }, { "input": "⁷Li", "note": "PDF 과학 제3항 질량수 예제", From 98c04bb2a0ea0f2f62a93d8e941caeaf508d1791 Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 03:18:43 +0900 Subject: [PATCH 049/132] Write a chain of elements as a structural formula Science article 10 writes a chain compound by joining the element symbols with bond lines -- a subscript sign followed by 1, 2 or 3 for single, double and triple -- and article 4 puts the whole thing in a capitals passage once three single-letter symbols run together. The encoder had none of it: O=C=O came out as maths, with the equals signs as comparison operators. A token rule in the normalisation phase now recognises the chain and writes it whole. Recognition is deliberately narrow: the letters must be real one-letter element symbols, the marks must be bond lines, and there must be at least three elements. A-B is left alone because A is not an element, which is the same guard the subscript work used. Two of the three fixtures pass. H-O-H does not, and it is not this rule's doing: its characters are all ASCII, so the document is routed to English before any token rule runs, and it comes out as a hyphenated English word. Moving that decision is a separate change. science 16/18. Corpus unchanged at 456025/467121. Suite 5281 + 20 + 8 + 351 + 162, fmt and clippy clean. --- libs/braillify/src/encoder.rs | 3 + libs/braillify/src/rules/token_rules/mod.rs | 1 + .../rules/token_rules/structural_formula.rs | 129 ++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 libs/braillify/src/rules/token_rules/structural_formula.rs diff --git a/libs/braillify/src/encoder.rs b/libs/braillify/src/encoder.rs index ddf93b1c..0730774f 100644 --- a/libs/braillify/src/encoder.rs +++ b/libs/braillify/src/encoder.rs @@ -144,6 +144,9 @@ impl Encoder { token_engine.register(Box::new( rules::token_rules::digital_notation::DigitalNotationRule, )); + token_engine.register(Box::new( + rules::token_rules::structural_formula::StructuralFormulaRule, + )); token_engine.register(Box::new( rules::token_rules::uppercase_passage::UppercasePassageRule, )); diff --git a/libs/braillify/src/rules/token_rules/mod.rs b/libs/braillify/src/rules/token_rules/mod.rs index b2d0dc47..bdb07cb5 100644 --- a/libs/braillify/src/rules/token_rules/mod.rs +++ b/libs/braillify/src/rules/token_rules/mod.rs @@ -14,5 +14,6 @@ pub mod roman_numeral; pub mod rule_33_citation; pub mod rule_73_appendix_placeholder; pub mod spacing; +pub mod structural_formula; pub mod uppercase_passage; pub mod word_shortcut; diff --git a/libs/braillify/src/rules/token_rules/structural_formula.rs b/libs/braillify/src/rules/token_rules/structural_formula.rs new file mode 100644 index 00000000..3add278a --- /dev/null +++ b/libs/braillify/src/rules/token_rules/structural_formula.rs @@ -0,0 +1,129 @@ +use crate::english::encode_english; +use crate::rules::context::EncoderState; +use crate::rules::token::Token; +use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; +use crate::unicode::decode_unicode; + +pub struct StructuralFormulaRule; + +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "10", + subsection: None, + name: "science_structural_formula", + standard_ref: "2024 Korean Braille Standard, 과학 제10항", + description: "사슬 화합물의 기호 표기 형식", +}; + +/// 한 글자로 된 원소 기호. 과학 제4항이 대문자 구절표를 요구하는 대상이다. +const SINGLE_LETTER_ELEMENTS: &[char] = &[ + 'H', 'B', 'C', 'N', 'O', 'F', 'P', 'S', 'K', 'V', 'Y', 'I', 'W', 'U', +]; + +/// 결합선. 과학 제10항 2 — ⠰을 먼저 적고 결합 수에 따라 1, 2, 3을 붙인다. +fn bond_cells(mark: char) -> Option<[u8; 2]> { + let count = match mark { + '-' => '⠂', + '=' => '⠆', + '≡' => '⠒', + _ => return None, + }; + Some([decode_unicode('⠰'), decode_unicode(count)]) +} + +/// 원소와 결합선이 번갈아 놓인 사슬인지 본다. +/// +/// 원소를 한 글자짜리 실제 원소 기호로만 한정하고 셋 이상을 요구한다. 수학의 +/// `A-B`는 A가 원소가 아니라서, 두 글자짜리 이름은 길이 때문에 걸리지 않는다. +fn chain_of_elements(text: &str) -> Option> { + let chars: Vec = text.chars().collect(); + if chars.len() < 5 || chars.len().is_multiple_of(2) { + return None; + } + let elements = chars.iter().step_by(2).count(); + if elements < 3 { + return None; + } + for (offset, mark) in chars.iter().enumerate() { + let valid = if offset % 2 == 0 { + SINGLE_LETTER_ELEMENTS.contains(mark) + } else { + bond_cells(*mark).is_some() + }; + if !valid { + return None; + } + } + Some(chars) +} + +fn encode_chain(chars: &[char]) -> Result, String> { + let capital = decode_unicode('⠠'); + let mut out = vec![capital, capital, capital]; + for (offset, mark) in chars.iter().enumerate() { + if offset % 2 == 0 { + out.push(encode_english(mark.to_ascii_lowercase())?); + } else { + out.extend(bond_cells(*mark).ok_or("not a bond line")?); + } + } + out.extend([capital, decode_unicode('⠄')]); + Ok(out) +} + +impl TokenRule for StructuralFormulaRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + + fn phase(&self) -> TokenPhase { + TokenPhase::Normalization + } + + fn priority(&self) -> u16 { + 1 + } + + fn apply<'a>( + &self, + tokens: &[Token<'a>], + index: usize, + _state: &mut EncoderState, + ) -> Result, String> { + let Some(Token::Word(word)) = tokens.get(index) else { + return Ok(TokenAction::Noop); + }; + let Some(chain) = chain_of_elements(word.text.as_ref()) else { + return Ok(TokenAction::Noop); + }; + Ok(TokenAction::Replace(Token::PreEncoded(encode_chain( + &chain, + )?))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[rstest::rstest] + #[case::single_bond("H-O-H", true)] + #[case::double_bond("O=C=O", true)] + #[case::mixed_bonds("H-C≡C-H", true)] + #[case::not_elements("A-B", false)] + #[case::two_elements("H-O", false)] + #[case::plain_word("water", false)] + fn recognises_only_element_chains(#[case] text: &str, #[case] expected: bool) { + assert_eq!(chain_of_elements(text).is_some(), expected); + } + + #[test] + fn writes_the_chain_inside_a_capitals_passage() { + let chain = chain_of_elements("H-O-H").expect("H-O-H is a chain"); + let cells = encode_chain(&chain).expect("chain encodes"); + let braille: String = cells + .iter() + .map(|cell| char::from_u32(0x2800 + u32::from(*cell)).expect("cell is braille")) + .collect(); + assert_eq!(braille, "⠠⠠⠠⠓⠰⠂⠕⠰⠂⠓⠠⠄"); + } +} From f6d6b2873f7a0534901cc192d90e899bdfdb227b Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 09:04:07 +0900 Subject: [PATCH 050/132] Route a structural formula away from English, but not a spelled word H-O-H is all ASCII, so the document went to the English engine before any token rule ran and came out as a hyphenated word. The UEB eligibility check now declines a chain of elements, which sends it down the Korean path where the structural formula rule writes it. That broke B-U-S. UEB 8.7.1 spells a word out with hyphens between its letters, and boron, uranium and sulphur are all one-letter element symbols, so the same shape is both. Across every fixture and the whole corpus that is the only collision -- measured, not assumed. A dictionary check could not tell them apart: hoh is in CMUdict. Chemistry can. A structural formula writes out a whole molecule, so every atom carries exactly as many bonds as its valence -- H-O-H does, with hydrogen at one and oxygen at two, while the end letters of B-U-S and S-O-S carry one bond each and are not molecules. Double and triple bonds are never used for spelling, so the check applies only to chains made of single bonds. Science article 10 now passes 3 of 3 and science stands at 17 of 18, with English 8.7.1 back at 14 of 14. Corpus unchanged at 456025/467121. Suite 5283 + 20 + 8 + 351 + 162, fmt and clippy clean. --- libs/braillify/src/rules/english_ueb/mod.rs | 5 ++ .../rules/token_rules/structural_formula.rs | 59 +++++++++++++++++-- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/libs/braillify/src/rules/english_ueb/mod.rs b/libs/braillify/src/rules/english_ueb/mod.rs index a5733104..30ca8e75 100644 --- a/libs/braillify/src/rules/english_ueb/mod.rs +++ b/libs/braillify/src/rules/english_ueb/mod.rs @@ -754,6 +754,11 @@ fn push_rule4_letter_without_leading_cap(c: char, out: &mut Vec) -> Option<( /// (U+0332) — so a *letterless* but emphasised input (`3̲4̲`, `27.̲9`, `83%̲`) is /// still UEB's. Korean is excluded by the callers' own `is_korean_char` guard. pub fn is_ueb_eligible(text: &str) -> bool { + // 과학 제10항의 구조식은 로마자만으로 이뤄지지만 영어 낱말이 아니다. 결합선으로 + // 이어진 원소 사슬은 한국어 경로의 구조식 규칙이 대문자 구절표와 함께 적는다. + if crate::rules::token_rules::structural_formula::chain_of_elements(text).is_some() { + return false; + } text.chars().any(|c| { c.is_ascii_alphabetic() || c == '\u{0332}' diff --git a/libs/braillify/src/rules/token_rules/structural_formula.rs b/libs/braillify/src/rules/token_rules/structural_formula.rs index 3add278a..794119a5 100644 --- a/libs/braillify/src/rules/token_rules/structural_formula.rs +++ b/libs/braillify/src/rules/token_rules/structural_formula.rs @@ -21,20 +21,28 @@ const SINGLE_LETTER_ELEMENTS: &[char] = &[ /// 결합선. 과학 제10항 2 — ⠰을 먼저 적고 결합 수에 따라 1, 2, 3을 붙인다. fn bond_cells(mark: char) -> Option<[u8; 2]> { - let count = match mark { - '-' => '⠂', - '=' => '⠆', - '≡' => '⠒', - _ => return None, + let count = match bond_order(mark)? { + 1 => '⠂', + 2 => '⠆', + _ => '⠒', }; Some([decode_unicode('⠰'), decode_unicode(count)]) } +fn bond_order(mark: char) -> Option { + match mark { + '-' => Some(1), + '=' => Some(2), + '≡' => Some(3), + _ => None, + } +} + /// 원소와 결합선이 번갈아 놓인 사슬인지 본다. /// /// 원소를 한 글자짜리 실제 원소 기호로만 한정하고 셋 이상을 요구한다. 수학의 /// `A-B`는 A가 원소가 아니라서, 두 글자짜리 이름은 길이 때문에 걸리지 않는다. -fn chain_of_elements(text: &str) -> Option> { +pub fn chain_of_elements(text: &str) -> Option> { let chars: Vec = text.chars().collect(); if chars.len() < 5 || chars.len().is_multiple_of(2) { return None; @@ -53,9 +61,46 @@ fn chain_of_elements(text: &str) -> Option> { return None; } } + // UEB §8.7.1 — 하이픈으로 글자를 띄운 낱말은 철자를 풀어 쓴 것이다(`B-U-S`). + // `=`·`≡`는 철자에 쓰이지 않으므로 모호한 것은 하이픈만으로 이은 사슬뿐이다. + // 그때는 사슬이 분자 하나를 온전히 이룰 때만 구조식으로 본다. + let single_bonds_only = chars.iter().skip(1).step_by(2).all(|mark| *mark == '-'); + if single_bonds_only && !is_complete_molecule(&chars) { + return None; + } Some(chars) } +/// 흔한 원자가. 값이 여럿인 전이 금속은 사슬만으로 분자를 확정할 수 없어 뺀다. +fn valence(element: char) -> Option { + match element { + 'H' | 'F' | 'K' | 'I' => Some(1), + 'O' | 'S' => Some(2), + 'B' | 'N' | 'P' | 'Y' => Some(3), + 'C' => Some(4), + _ => None, + } +} + +/// 모든 원자의 결합 수가 원자가와 같은가. `H-O-H`는 H 1·O 2로 맞고, +/// `S-O-S`는 양끝 S가 결합 하나뿐이라 분자가 아니다. +fn is_complete_molecule(chars: &[char]) -> bool { + let order = |offset: usize| { + chars + .get(offset) + .and_then(|mark| bond_order(*mark)) + .unwrap_or(0) + }; + chars + .iter() + .enumerate() + .step_by(2) + .all(|(offset, element)| { + let bonds = offset.checked_sub(1).map_or(0, order) + order(offset + 1); + valence(*element) == Some(bonds) + }) +} + fn encode_chain(chars: &[char]) -> Result, String> { let capital = decode_unicode('⠠'); let mut out = vec![capital, capital, capital]; @@ -110,6 +155,8 @@ mod tests { #[case::double_bond("O=C=O", true)] #[case::mixed_bonds("H-C≡C-H", true)] #[case::not_elements("A-B", false)] + #[case::spelled_out_word("B-U-S", false)] + #[case::spelled_out_distress("S-O-S", false)] #[case::two_elements("H-O", false)] #[case::plain_word("water", false)] fn recognises_only_element_chains(#[case] text: &str, #[case] expected: bool) { From a8f33688587738173451b4da6e1e591847cb2d6b Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 09:15:02 +0900 Subject: [PATCH 051/132] Write a galvanic cell the way science article 21 does The salt bridge and the parallel sign are the same character. Article 21 writes the bridge as two electrode cells with a blank on each side; maths article 44 writes parallel as its own sign, which is what the encoder always produced. Neither is wrong, so the fixture that tested a bare parallel sign against the bridge form could never pass -- a bare sign is parallel, the same way a bare hyphen is a hyphen. It is replaced with the two cell diagrams the PDF actually prints. A normalisation token rule now recognises a cell diagram and writes it whole: electrodes and the bridge spaced, element symbols capitalised one by one with subscripts after them, state symbols in the Korean round brackets article 18 asks for, polarity signs, and Korean inside the diagram wrapped in the Korean indicator and terminator. The recogniser needs a bridge and an electrode, must start and end on a formula or a polarity sign, and takes Korean only as the label of the electrode right after it -- so a sentence's own opening words are never pulled in, and AB parallel to CD is left to maths. Science now passes 18 of 18. Corpus unchanged at 456025/467121. Suite 5290 + 20 + 8 + 351 + 162, fmt and clippy clean. --- libs/braillify/src/encoder.rs | 3 + .../src/rules/token_rules/cell_notation.rs | 279 ++++++++++++++++++ libs/braillify/src/rules/token_rules/mod.rs | 1 + test_cases/science/science_21.json | 20 +- 4 files changed, 293 insertions(+), 10 deletions(-) create mode 100644 libs/braillify/src/rules/token_rules/cell_notation.rs diff --git a/libs/braillify/src/encoder.rs b/libs/braillify/src/encoder.rs index 0730774f..b65520b8 100644 --- a/libs/braillify/src/encoder.rs +++ b/libs/braillify/src/encoder.rs @@ -147,6 +147,9 @@ impl Encoder { token_engine.register(Box::new( rules::token_rules::structural_formula::StructuralFormulaRule, )); + token_engine.register(Box::new( + rules::token_rules::cell_notation::CellNotationRule, + )); token_engine.register(Box::new( rules::token_rules::uppercase_passage::UppercasePassageRule, )); diff --git a/libs/braillify/src/rules/token_rules/cell_notation.rs b/libs/braillify/src/rules/token_rules/cell_notation.rs new file mode 100644 index 00000000..920aaa7d --- /dev/null +++ b/libs/braillify/src/rules/token_rules/cell_notation.rs @@ -0,0 +1,279 @@ +use crate::english::encode_english; +use crate::number::encode_number; +use crate::rules::context::EncoderState; +use crate::rules::token::Token; +use crate::rules::token_rule::{TokenAction, TokenPhase, TokenRule}; +use crate::unicode::decode_unicode; + +pub struct CellNotationRule; + +static META: crate::rules::RuleMeta = crate::rules::RuleMeta { + section: "21", + subsection: None, + name: "science_cell_notation", + standard_ref: "2024 Korean Braille Standard, 과학 제21항", + description: "전지의 구조를 나타낸 화학식", +}; + +const ELECTRODE: char = '\u{2223}'; +const SALT_BRIDGE: char = '\u{2225}'; + +const ELEMENTS: &[&str] = &[ + "H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne", "Na", "Mg", "Al", "Si", "P", "S", "Cl", + "Ar", "K", "Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", "Cu", "Zn", "Ga", "Ge", "As", + "Se", "Br", "Kr", "Rb", "Sr", "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd", "In", + "Sn", "Sb", "Te", "I", "Xe", "Cs", "Ba", "La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd", "Tb", + "Dy", "Ho", "Er", "Tm", "Yb", "Lu", "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg", "Tl", + "Pb", "Bi", "Po", "At", "Rn", "Fr", "Ra", "Ac", "Th", "Pa", "U", "Np", "Pu", "Am", "Cm", "Bk", + "Cf", "Es", "Fm", "Md", "No", "Lr", "Rf", "Db", "Sg", "Bh", "Hs", "Mt", "Ds", "Rg", "Cn", "Nh", + "Fl", "Mc", "Lv", "Ts", "Og", +]; + +/// 상태 기호. 과학 제18항 3 — 구별된 글자체를 나타내는 괄호는 한글 소괄호로 적는다. +const STATES: &[&str] = &["aq", "s", "l", "g"]; + +enum Item<'t> { + Word(&'t str), + Junction(char), +} + +fn items(text: &str) -> Vec> { + let mut items = Vec::new(); + let mut start = None; + for (offset, ch) in text.char_indices() { + let breaks = ch.is_whitespace() || ch == ELECTRODE || ch == SALT_BRIDGE; + if breaks { + if let Some(from) = start.take() { + items.push(Item::Word(&text[from..offset])); + } + if !ch.is_whitespace() { + items.push(Item::Junction(ch)); + } + } else if start.is_none() { + start = Some(offset); + } + } + if let Some(from) = start { + items.push(Item::Word(&text[from..])); + } + items +} + +fn subscript_digit(ch: char) -> Option { + ('\u{2080}'..='\u{2089}') + .contains(&ch) + .then(|| char::from(b'0' + (ch as u32 - 0x2080) as u8)) +} + +/// 과학 제7항 — 원소 기호마다 대문자표, 아래 첨자는 ⠰ 뒤에 수. +fn formula_cells(word: &str) -> Option> { + let chars: Vec = word.chars().collect(); + let mut out = Vec::new(); + let mut elements = 0; + let mut at = 0; + while at < chars.len() { + let ch = chars[at]; + if ch.is_ascii_uppercase() { + let two = chars + .get(at + 1) + .filter(|next| next.is_ascii_lowercase()) + .map(|next| format!("{ch}{next}")); + let symbol = match two { + Some(name) if ELEMENTS.contains(&name.as_str()) => name, + Some(_) => return None, + None if ELEMENTS.contains(&ch.to_string().as_str()) => ch.to_string(), + None => return None, + }; + out.push(decode_unicode('⠠')); + for letter in symbol.chars() { + out.push(encode_english(letter.to_ascii_lowercase()).ok()?); + } + at += symbol.len(); + elements += 1; + } else if subscript_digit(ch).is_some() { + out.extend([decode_unicode('⠰'), decode_unicode('⠼')]); + while let Some(digit) = chars.get(at).copied().and_then(subscript_digit) { + out.push(encode_number(digit).ok()?); + at += 1; + } + } else if ch == '(' { + let close = chars[at..].iter().position(|c| *c == ')')? + at; + let state: String = chars[at + 1..close].iter().collect(); + if !STATES.contains(&state.as_str()) { + return None; + } + out.extend([ + decode_unicode('⠦'), + decode_unicode('⠄'), + decode_unicode('⠴'), + ]); + for letter in state.chars() { + out.push(encode_english(letter).ok()?); + } + out.extend([decode_unicode('⠠'), decode_unicode('⠴')]); + at = close + 1; + } else { + return None; + } + } + (elements > 0).then_some(out) +} + +fn polarity_cells(word: &str) -> Option<[u8; 3]> { + let sign = match word { + "(-)" => '⠔', + "(+)" => '⠢', + _ => return None, + }; + Some([ + decode_unicode('⠦'), + decode_unicode(sign), + decode_unicode('⠴'), + ]) +} + +fn is_hangul_word(word: &str) -> bool { + word.chars().all(|c| ('\u{AC00}'..='\u{D7A3}').contains(&c)) +} + +/// 과학 제21항 — 전극은 ⠳, 염다리는 ⠳⠳으로 적고 앞뒤를 한 칸씩 띄운다. 식에 +/// 포함된 한글은 한글표 ⠸⠷와 한글 종료표 ⠸⠾로 묶는다. +/// +/// 전지 표기가 아니면 `None`. 염다리와 전극이 모두 있어야 하고, 식의 처음과 끝은 +/// 화학식이나 극성이어야 하며, 한글은 바로 뒤에 전극이나 염다리가 올 때만 그 +/// 전극의 설명으로 받는다. 그래서 문장 앞머리의 한글이 식으로 빨려 들지 않는다. +pub(crate) fn encode_cell(text: &str) -> Option> { + if !text.contains(SALT_BRIDGE) || !text.contains(ELECTRODE) { + return None; + } + let items = items(text); + let is_edge = |item: Option<&Item<'_>>| match item { + Some(Item::Word(word)) => polarity_cells(word).is_some() || formula_cells(word).is_some(), + _ => false, + }; + if !is_edge(items.first()) || !is_edge(items.last()) { + return None; + } + let blank = decode_unicode('⠀'); + let mut out = Vec::new(); + let mut after_junction = true; + for (position, item) in items.iter().enumerate() { + match item { + Item::Junction(mark) => { + out.push(blank); + out.push(decode_unicode('⠳')); + if *mark == SALT_BRIDGE { + out.push(decode_unicode('⠳')); + } + out.push(blank); + after_junction = true; + } + Item::Word(word) => { + if !after_junction { + out.push(blank); + } + after_junction = false; + if let Some(cells) = polarity_cells(word) { + out.extend(cells); + } else if let Some(cells) = formula_cells(word) { + out.extend(cells); + } else if is_hangul_word(word) + && matches!(items.get(position + 1), Some(Item::Junction(_))) + { + out.extend([decode_unicode('⠸'), decode_unicode('⠷')]); + out.extend(crate::encode(word).ok()?); + out.extend([decode_unicode('⠸'), decode_unicode('⠾')]); + } else { + return None; + } + } + } + } + Some(out) +} + +impl TokenRule for CellNotationRule { + fn meta(&self) -> &'static crate::rules::RuleMeta { + &META + } + + fn phase(&self) -> TokenPhase { + TokenPhase::Normalization + } + + fn priority(&self) -> u16 { + 1 + } + + fn apply<'a>( + &self, + tokens: &[Token<'a>], + index: usize, + _state: &mut EncoderState, + ) -> Result, String> { + if !matches!(tokens.get(index), Some(Token::Word(_))) { + return Ok(TokenAction::Noop); + } + let run = tokens[index..] + .iter() + .take_while(|token| matches!(token, Token::Word(_) | Token::Space(_))) + .count(); + let has_bridge = tokens[index..index + run] + .iter() + .any(|token| matches!(token, Token::Word(word) if word.text.contains(SALT_BRIDGE))); + if !has_bridge { + return Ok(TokenAction::Noop); + } + for len in (1..=run).rev() { + let text: String = tokens[index..index + len] + .iter() + .map(|token| match token { + Token::Word(word) => word.text.as_ref(), + _ => " ", + }) + .collect(); + if let Some(cells) = encode_cell(&text) { + return Ok(TokenAction::ReplaceRange( + len, + vec![Token::PreEncoded(cells)], + )); + } + } + Ok(TokenAction::Noop) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn braille(cells: &[u8]) -> String { + cells + .iter() + .map(|cell| char::from_u32(0x2800 + u32::from(*cell)).expect("cell is braille")) + .collect() + } + + #[rstest::rstest] + #[case::daniell( + "Cu ∣CuSO₄(aq)∥ZnSO₄(aq)∣Zn", + "⠠⠉⠥⠀⠳⠀⠠⠉⠥⠠⠎⠠⠕⠰⠼⠙⠦⠄⠴⠁⠟⠠⠴⠀⠳⠳⠀⠠⠵⠝⠠⠎⠠⠕⠰⠼⠙⠦⠄⠴⠁⠟⠠⠴⠀⠳⠀⠠⠵⠝" + )] + #[case::with_hangul_and_polarity( + "(-) Zn∣NH₄Cl 포화용액∥MnO₂∣C (+)", + "⠦⠔⠴⠀⠠⠵⠝⠀⠳⠀⠠⠝⠠⠓⠰⠼⠙⠠⠉⠇⠀⠸⠷⠙⠥⠚⠧⠬⠶⠗⠁⠸⠾⠀⠳⠳⠀⠠⠍⠝⠠⠕⠰⠼⠃⠀⠳⠀⠠⠉⠀⠦⠢⠴" + )] + fn writes_a_cell_diagram(#[case] text: &str, #[case] expected: &str) { + assert_eq!(braille(&encode_cell(text).expect("is a cell")), expected); + } + + #[rstest::rstest] + #[case::parallel_lines("AB∥CD")] + #[case::bridge_without_electrode("Zn∥Cu")] + #[case::leading_prose("전지는 Zn∣ZnSO₄∥CuSO₄∣Cu")] + #[case::not_an_element("Qx∣Zn∥Cu∣Zn")] + #[case::unknown_state("Zn(zz)∣Zn∥Cu∣Cu")] + fn leaves_everything_else_alone(#[case] text: &str) { + assert!(encode_cell(text).is_none()); + } +} diff --git a/libs/braillify/src/rules/token_rules/mod.rs b/libs/braillify/src/rules/token_rules/mod.rs index bdb07cb5..9223f5c5 100644 --- a/libs/braillify/src/rules/token_rules/mod.rs +++ b/libs/braillify/src/rules/token_rules/mod.rs @@ -1,3 +1,4 @@ +pub mod cell_notation; pub mod digital_notation; pub mod emphasis_ring; pub mod english_dominant_korean_wrap; diff --git a/test_cases/science/science_21.json b/test_cases/science/science_21.json index 859a6a6f..d513dd39 100644 --- a/test_cases/science/science_21.json +++ b/test_cases/science/science_21.json @@ -1,19 +1,19 @@ [ { - "input": "∣", - "note": "PDF 과학 제21항 전극 표시 정의", - "internal": "|", - "expected": "51", - "unicode": "⠳", + "input": "Cu ∣CuSO₄(aq)∥ZnSO₄(aq)∣Zn", + "note": "PDF 과학 제21항 — 전극 \\, 염다리 \\\\ 앞뒤 한 칸, 상태 괄호는 제18항 3의 한글 소괄호", + "internal": ",cu`\\`,cu,s,o;#d8'0aq,0`\\\\`,zn,s,o;#d8'0aq,0`\\`,zn", + "expected": "3293705103293732143221486025384521313252051510325329321432214860253845213132520510325329", + "unicode": "⠠⠉⠥⠀⠳⠀⠠⠉⠥⠠⠎⠠⠕⠰⠼⠙⠦⠄⠴⠁⠟⠠⠴⠀⠳⠳⠀⠠⠵⠝⠠⠎⠠⠕⠰⠼⠙⠦⠄⠴⠁⠟⠠⠴⠀⠳⠀⠠⠵⠝", "world": "", "jeomsarang": "" }, { - "input": "∥", - "note": "PDF 과학 제21항 염다리 표시 정의", - "internal": "||", - "expected": "5151", - "unicode": "⠳⠳", + "input": "(-) Zn∣NH₄Cl 포화용액∥MnO₂∣C (+)", + "note": "PDF 과학 제21항 — 식 안의 한글은 한글표 _( 와 한글 종료표 _) 로 묶는다", + "internal": "890`,zn`\\`,n,h;#d,cl`_(dujv+7ra_)`\\\\`,mn,o;#b`\\`,c`850", + "expected": "3820520325329051032293219486025329705655253726394454231566205151032132932214860305103290383452", + "unicode": "⠦⠔⠴⠀⠠⠵⠝⠀⠳⠀⠠⠝⠠⠓⠰⠼⠙⠠⠉⠇⠀⠸⠷⠙⠥⠚⠧⠬⠶⠗⠁⠸⠾⠀⠳⠳⠀⠠⠍⠝⠠⠕⠰⠼⠃⠀⠳⠀⠠⠉⠀⠦⠢⠴", "world": "", "jeomsarang": "" } From 2a23a74075ee1890dd44dc62e91728cf0dfb8f96 Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 09:19:46 +0900 Subject: [PATCH 052/132] Hold the science fixtures to the same bar as Korean and maths The science groups were registered as benchmarks because the chemistry engine did not exist and a regulation fixture that cannot pass would fail the gate. They all pass now, 18 of 18, so the benchmark flag comes off: test_by_testcase requires them at 100% like every other regulation group, and a regression in any chemistry article now fails the build instead of lowering a percentage. The integrity file drops the comment that explained why science had no conversion run, and science joins runConversionTests. One thing the conversion run cannot prove: it wraps its expect in a try whose catch swallows the assertion, so it never fails for any directory. That is older than this change and is left alone here; test_by_testcase is what actually enforces the answers. Suite 5290 + 20 + 8 + 351 + 162, testcase integrity 14279, corpus unchanged at 456025/467121. --- rule_map.json | 18 ++++++------------ test_cases/testcase-integrity.test.ts | 4 +--- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/rule_map.json b/rule_map.json index 77eeea0b..446c04ff 100644 --- a/rule_map.json +++ b/rule_map.json @@ -1803,32 +1803,26 @@ }, "science/science_7": { "title": "과학 제7항", - "description": "화학식은 원소 기호 앞에 대문자 기호표를 적고, 아래 첨자는 ⠰ 뒤에 내용을 적는다.", - "benchmark": true + "description": "화학식은 원소 기호 앞에 대문자 기호표를 적고, 아래 첨자는 ⠰ 뒤에 내용을 적는다." }, "science/science_18": { "title": "과학 제18항", - "description": "화학 반응식의 기호는 앞뒤를 한 칸씩 띄어 쓰고, 기체 발생과 침전 기호는 분자식에 붙여 적는다.", - "benchmark": true + "description": "화학 반응식의 기호는 앞뒤를 한 칸씩 띄어 쓰고, 기체 발생과 침전 기호는 분자식에 붙여 적는다." }, "science/science_3": { "title": "과학 제3항", - "description": "원소 기호를 먼저 적고 원자 번호는 아래 첨자로, 질량수는 위 첨자로 적는다.", - "benchmark": true + "description": "원소 기호를 먼저 적고 원자 번호는 아래 첨자로, 질량수는 위 첨자로 적는다." }, "science/science_10": { "title": "과학 제10항", - "description": "사슬 화합물의 결합선은 ⠰을 먼저 적고 결합 수에 따라 단일 1, 이중 2, 삼중 3을 붙여 적는다.", - "benchmark": true + "description": "사슬 화합물의 결합선은 ⠰을 먼저 적고 결합 수에 따라 단일 1, 이중 2, 삼중 3을 붙여 적는다." }, "science/science_21": { "title": "과학 제21항", - "description": "전지의 전극 표시와 염다리 표시는 앞뒤를 한 칸씩 띄어 적는다.", - "benchmark": true + "description": "전지의 전극 표시와 염다리 표시는 앞뒤를 한 칸씩 띄어 적는다." }, "science/science_22": { "title": "과학 제22항", - "description": "여성 기호와 남성 기호를 적는 방법을 정한다.", - "benchmark": true + "description": "여성 기호와 남성 기호를 적는 방법을 정한다." } } diff --git a/test_cases/testcase-integrity.test.ts b/test_cases/testcase-integrity.test.ts index bbc82d10..9997224a 100644 --- a/test_cases/testcase-integrity.test.ts +++ b/test_cases/testcase-integrity.test.ts @@ -215,11 +215,9 @@ function runShardedIntegrityTests(dir: string, label: string) { runIntegrityTests('korean', 'Korean') runIntegrityTests('math', 'Math') -// The chemistry engine is not written yet, so these fixtures are checked for -// internal consistency only — `runConversionTests` would ask the encoder to -// produce output it cannot produce. runIntegrityTests('science', 'Science') runShardedIntegrityTests('2024_corpus', 'NIKL 2024 corpus') runShardedIntegrityTests('2025_corpus', 'NIKL 2025 corpus') runConversionTests('korean', 'Korean') runConversionTests('math', 'Math') +runConversionTests('science', 'Science') From bedf9774f821a72b060dfb37035f46c63d52b04d Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 10:08:00 +0900 Subject: [PATCH 053/132] Cover the chemistry branches the gate found unexercised CI measured 99.96% with seven lines uncovered, and reproducing its reformat locally named each one. One was unreachable: a chain of five or more odd-length characters always has at least three elements, so the separate three-element check could never fire. It is gone. Four were real branches with no case: a lone capital that is not an element, a diagram that opens on an electrode, a middle word that is neither formula nor label, and the carbon and unknown-valence arms of the valence table, which short-circuiting had kept every existing case from reaching. The last was the prime arm in the element-run check, and following it showed a wrong answer rather than a missing test: primes were skipped, so O prime H counted as two element symbols. A primed capital is a maths variable, so the check now requires every token in the run to be an element capital, which also removes the arm. Suite 5295 + 20 + 8 + 351 + 162, science 18/18 enforced, corpus unchanged at 456025/467121, fmt and clippy clean. --- libs/braillify/src/rules/math/rule_12.rs | 38 +++++-------------- .../src/rules/token_rules/cell_notation.rs | 3 ++ .../rules/token_rules/structural_formula.rs | 6 +-- 3 files changed, 14 insertions(+), 33 deletions(-) diff --git a/libs/braillify/src/rules/math/rule_12.rs b/libs/braillify/src/rules/math/rule_12.rs index d1466df7..0cfadb39 100644 --- a/libs/braillify/src/rules/math/rule_12.rs +++ b/libs/braillify/src/rules/math/rule_12.rs @@ -252,36 +252,16 @@ pub fn encode_upper_variable( /// 아래 첨자가 있는 식으로 한정한다. 한 글자짜리 원소 기호는 수학 변수와 /// 글자가 겹치므로(`P`, `V`, `B`, `C`), 첨자라는 화학식 신호가 없으면 /// 행렬 아닌 대문자 변수까지 갈라놓게 된다. + /// + /// 프라임이 낀 대문자열(`O′H`)은 수학 변수이지 원소 기호의 나열이 아니다. fn names_element_symbols(tokens: &[MathToken], start: usize, end: usize) -> bool { - if !tokens.iter().any(|t| matches!(t, MathToken::Subscript(_))) { - return false; - } - let letters: Vec = tokens[start..end] - .iter() - .filter_map(|token| match token { - MathToken::UpperVariable(letter) => Some(*letter), - _ => None, - }) - .collect(); - letters.len() >= 2 - && letters.iter().all(|letter| { - matches!( - letter, - 'H' | 'B' - | 'C' - | 'N' - | 'O' - | 'F' - | 'P' - | 'S' - | 'K' - | 'V' - | 'Y' - | 'I' - | 'W' - | 'U' - ) - }) + const ELEMENTS: &[char] = &[ + 'H', 'B', 'C', 'N', 'O', 'F', 'P', 'S', 'K', 'V', 'Y', 'I', 'W', 'U', + ]; + let is_element = |token: &MathToken| matches!(token, MathToken::UpperVariable(letter) if ELEMENTS.contains(letter)); + tokens.iter().any(|t| matches!(t, MathToken::Subscript(_))) + && end - start >= 2 + && tokens[start..end].iter().all(is_element) } let mut seq_end = *i; diff --git a/libs/braillify/src/rules/token_rules/cell_notation.rs b/libs/braillify/src/rules/token_rules/cell_notation.rs index 920aaa7d..65c4b4ea 100644 --- a/libs/braillify/src/rules/token_rules/cell_notation.rs +++ b/libs/braillify/src/rules/token_rules/cell_notation.rs @@ -273,6 +273,9 @@ mod tests { #[case::leading_prose("전지는 Zn∣ZnSO₄∥CuSO₄∣Cu")] #[case::not_an_element("Qx∣Zn∥Cu∣Zn")] #[case::unknown_state("Zn(zz)∣Zn∥Cu∣Cu")] + #[case::lone_non_element("Q∣Zn∥Cu∣Zn")] + #[case::starts_with_electrode("∣Zn∥Cu∣Zn")] + #[case::stray_word("Zn∣xyz∥Cu∣Cu")] fn leaves_everything_else_alone(#[case] text: &str) { assert!(encode_cell(text).is_none()); } diff --git a/libs/braillify/src/rules/token_rules/structural_formula.rs b/libs/braillify/src/rules/token_rules/structural_formula.rs index 794119a5..76fdbd5e 100644 --- a/libs/braillify/src/rules/token_rules/structural_formula.rs +++ b/libs/braillify/src/rules/token_rules/structural_formula.rs @@ -47,10 +47,6 @@ pub fn chain_of_elements(text: &str) -> Option> { if chars.len() < 5 || chars.len().is_multiple_of(2) { return None; } - let elements = chars.iter().step_by(2).count(); - if elements < 3 { - return None; - } for (offset, mark) in chars.iter().enumerate() { let valid = if offset % 2 == 0 { SINGLE_LETTER_ELEMENTS.contains(mark) @@ -157,6 +153,8 @@ mod tests { #[case::not_elements("A-B", false)] #[case::spelled_out_word("B-U-S", false)] #[case::spelled_out_distress("S-O-S", false)] + #[case::unsaturated_carbon("H-C-H", false)] + #[case::unknown_valence("U-O-U", false)] #[case::two_elements("H-O", false)] #[case::plain_word("water", false)] fn recognises_only_element_chains(#[case] text: &str, #[case] expected: bool) { From 9da40685671b9ceb7d7676cf159f87a2382d0aa5 Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 16:37:55 +0900 Subject: [PATCH 054/132] Name every cell the English engine writes around lone capitals The grade-1 indicator before a lone capital (A, B) and a spelled run was pushed without a record, and the shortform collision test in rule 10.9 left its trial encodings recorded, so a later identical word (CO ... CO) matched them and the real letter lost its claim. Both now attribute correctly; the workbook has no unattributed cell left (was 4 in 2 rows). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/rules/english_ueb/engine/encode_word.rs | 12 ++++++++++-- libs/braillify/src/rules/english_ueb/mod.rs | 2 ++ libs/braillify/src/rules/english_ueb/rule_10_9.rs | 9 +++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/libs/braillify/src/rules/english_ueb/engine/encode_word.rs b/libs/braillify/src/rules/english_ueb/engine/encode_word.rs index 73f48784..6faa3102 100644 --- a/libs/braillify/src/rules/english_ueb/engine/encode_word.rs +++ b/libs/braillify/src/rules/english_ueb/engine/encode_word.rs @@ -592,7 +592,11 @@ macro_rules! encode_word_arm { && (matches!(spelled_run, Some((start, _)) if start == $i) || matches!(initialism_run, Some((start, _)) if start == $i)) { - $out.extend([GRADE1, GRADE1]); + super::push_indicator( + &mut $out, + super::UebMoveSource::Grade1Indicator, + &[GRADE1, GRADE1], + ); } let letter_grade1 = !$cap_start_grade1 && spelled_run.is_none() @@ -604,7 +608,11 @@ macro_rules! encode_word_arm { && super::rule_5_7::is_wordsign_letter($chars[0]) && matches!(next, Some(EnglishToken::Symbol('!'))))); if after_number_grade1 || letter_grade1 || apostrophe_wrapped_letter($tokens, $i, $chars) { - $out.push(GRADE1); + super::push_indicator( + &mut $out, + super::UebMoveSource::Grade1Indicator, + &[GRADE1], + ); } if !$foreign_passage && document_all_words($tokens).len() >= 3 diff --git a/libs/braillify/src/rules/english_ueb/mod.rs b/libs/braillify/src/rules/english_ueb/mod.rs index 30ca8e75..f9d54ee9 100644 --- a/libs/braillify/src/rules/english_ueb/mod.rs +++ b/libs/braillify/src/rules/english_ueb/mod.rs @@ -1319,6 +1319,8 @@ mod encode_pipeline_tests { #[case::camel_caps_word("dCO")] #[case::balanced_equation("aMgO(s)$+$bC(s)→cMg(s)$+$dCO(g)$+$eCO2(g)")] #[case::repeated_subscript_markup("CO2, SO2, CO2")] + #[case::word_repeated_later_in_the_line("CO$+$H2O↔CO2$+$H2")] + #[case::lone_capitals_between_inline_spans("A $1s^{2}2s^{2}2p^{5}$, B $1s^{2}2s^{2}2p^{2}$")] fn every_cell_of_a_chemical_line_names_a_rule(#[case] input: &str) { let (cells, trace) = crate::encode_with_trace(input).expect("input must encode"); let untraced = crate::encode(input).expect("input must encode untraced"); diff --git a/libs/braillify/src/rules/english_ueb/rule_10_9.rs b/libs/braillify/src/rules/english_ueb/rule_10_9.rs index e8d23d47..ae9f29c0 100644 --- a/libs/braillify/src/rules/english_ueb/rule_10_9.rs +++ b/libs/braillify/src/rules/english_ueb/rule_10_9.rs @@ -139,7 +139,10 @@ fn rule_10_9_3_reading_exists(shortform: &str, suffix: &[char]) -> bool { /// and would consequently miss cell-equivalent sequences such as `fst` (`f` + /// the `st` groupsign) and `shd` (the `sh` groupsign + `d`). fn korean_letter_sequence_cells(letters: &[char]) -> Vec { - super::span::encode_korean_word( + // A collision test only compares cells. Left recorded, its attempts are + // matched against a later identical word in the output (`CO … CO`). + let checkpoint = super::attribution_checkpoint(); + let cells = super::span::encode_korean_word( letters, true, // capitalization indicators are compared separately false, // do not recursively prepend grade 1 false, // rule 37 suppresses whole-word signs on Roman entry @@ -150,7 +153,9 @@ fn korean_letter_sequence_cells(letters: &[char]) -> Vec { false, // not split by an apostrophe false, // lowercase, so never a §10.12.1 initialism ) - .expect("a lowercase ASCII letters-sequence must be encodable") + .expect("a lowercase ASCII letters-sequence must be encodable"); + super::rollback_attributions(checkpoint); + cells } /// Encode a word as the §10.10.2 cell-minimising contraction sequence. From af0ff838d15702108b1335f9549a7534dd2a9f1a Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 16:37:55 +0900 Subject: [PATCH 055/132] Keep a function and its single argument as one fraction term sin i over sin r (science article 20) was grouped as if the numerator held two terms. The decision matrix test moves to rstest. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../rules/token_rules/latex_math/grouping.rs | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/libs/braillify/src/rules/token_rules/latex_math/grouping.rs b/libs/braillify/src/rules/token_rules/latex_math/grouping.rs index cf96a410..496b001b 100644 --- a/libs/braillify/src/rules/token_rules/latex_math/grouping.rs +++ b/libs/braillify/src/rules/token_rules/latex_math/grouping.rs @@ -78,6 +78,14 @@ pub(super) fn needs_grouping_in_fraction(expr: &str) -> bool { if chars.is_empty() { return false; } + // 함수와 그 인수 하나(`sin i`)는 한 항이다 — 과학 제20항 `sin i / sin r`. + if let Some((name, _)) = crate::rules::math::function::match_function_prefix(expr) + && let argument = expr[name.len()..].trim_start() + && (argument.chars().count() == 1 && argument.chars().all(|c| c.is_ascii_alphabetic()) + || !argument.is_empty() && argument.chars().all(|c| c.is_ascii_digit())) + { + return false; + } if chars.first() == Some(&'(') && chars.last() == Some(&')') { // 외곽이 단일 괄호 쌍이면 wrap 불필요. 단, `(...)(...)` 같이 인접한 다중 괄호 // 그룹이면 외곽이 단일 쌍이 아니므로 wrap 필요. @@ -224,35 +232,29 @@ mod tests { } /// `needs_grouping_in_fraction` decision matrix. - #[test] - fn fraction_grouping_decision_matrix() { - // Empty body → false - assert!(!needs_grouping_in_fraction("")); - // Single outer paren pair → false (single-pair check at line 81-101) - assert!(!needs_grouping_in_fraction("(x+1)")); - // Adjacent paren pairs → true (depth returns to 0 before end) - assert!(needs_grouping_in_fraction("(a)(b)")); - // Arithmetic operator → true - assert!(needs_grouping_in_fraction("a+b")); - assert!(needs_grouping_in_fraction("a-b")); - assert!(needs_grouping_in_fraction("a\u{00D7}b")); - assert!(needs_grouping_in_fraction("a\u{00F7}b")); - assert!(needs_grouping_in_fraction("a\u{2212}b")); - // Space at top level → true - assert!(needs_grouping_in_fraction("a b")); - // Partial-derivative `∂` → true (multi-token form) - assert!(needs_grouping_in_fraction("\u{2202}f")); - // Differential `dx` etc. → false - assert!(!needs_grouping_in_fraction("dx")); - assert!(!needs_grouping_in_fraction("dxy")); - // 2+ adjacent paren groups → true - assert!(needs_grouping_in_fraction("(x)(y)(z)")); - // Single alpha char only → false (single letter denominator) - assert!(!needs_grouping_in_fraction("a")); - // Pure digits → false (no alpha, no operator) - assert!(!needs_grouping_in_fraction("123")); - // Multiple alpha chars (non-differential prefix) → true - // (e.g., variable product like "ab" treated as multi-token) - assert!(needs_grouping_in_fraction("ab")); + #[rstest::rstest] + #[case::empty("", false)] + #[case::single_outer_paren("(x+1)", false)] + #[case::adjacent_parens("(a)(b)", true)] + #[case::plus("a+b", true)] + #[case::minus("a-b", true)] + #[case::times("a\u{00D7}b", true)] + #[case::divide("a\u{00F7}b", true)] + #[case::unicode_minus("a\u{2212}b", true)] + #[case::top_level_space("a b", true)] + #[case::partial_derivative("\u{2202}f", true)] + #[case::differential("dx", false)] + #[case::differential_product("dxy", false)] + #[case::three_paren_groups("(x)(y)(z)", true)] + #[case::single_letter("a", false)] + #[case::digits("123", false)] + #[case::variable_product("ab", true)] + #[case::function_of_one_letter("sin i", false)] + #[case::function_joined_to_its_letter("sini", false)] + #[case::hyperbolic_function_alone("sinh", true)] + #[case::function_of_a_number("sin 30", false)] + #[case::function_of_a_product("sin ab", true)] + fn fraction_grouping_decision_matrix(#[case] body: &str, #[case] expected: bool) { + assert_eq!(needs_grouping_in_fraction(body), expected); } } From 1cb8b6f645d412a7cbc37235577ad3683e2341a0 Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 16:37:55 +0900 Subject: [PATCH 056/132] Write a slash-joined unit such as kgf/m2 as one Roman span Korean article 69 [appendix 3]: the unit after the slash continues the span and takes no second Roman indicator. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/braillify/src/rules/korean/rule_68.rs | 7 +++++++ libs/braillify/src/rules/korean/rule_69.rs | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/libs/braillify/src/rules/korean/rule_68.rs b/libs/braillify/src/rules/korean/rule_68.rs index fe64769a..16eaf594 100644 --- a/libs/braillify/src/rules/korean/rule_68.rs +++ b/libs/braillify/src/rules/korean/rule_68.rs @@ -230,6 +230,13 @@ impl BrailleRule for Rule68 { return Ok(RuleResult::Skip); }; let is_roman_unit = matches!(ctx.current_char(), '㎡' | '㏊'); + // 제69항 [붙임 3] — 빗금으로 이어진 로마자 단위(`kgf/㎡`)는 한 로마자 구간이다. + if is_roman_unit + && super::rule_69::roman_unit_chain_continues_before(ctx) + && encoded.first() == Some(&ROMAN_INDICATOR) + { + encoded.remove(0); + } let continues = is_roman_unit && super::rule_69::adjust_roman_unit_boundary(ctx, ctx.index + 1, &mut encoded); ctx.emit_slice(&encoded); diff --git a/libs/braillify/src/rules/korean/rule_69.rs b/libs/braillify/src/rules/korean/rule_69.rs index 01e7a6bd..6fd31448 100644 --- a/libs/braillify/src/rules/korean/rule_69.rs +++ b/libs/braillify/src/rules/korean/rule_69.rs @@ -187,10 +187,10 @@ fn encode_compatibility_unit( } fn is_roman_unit_component(ch: char) -> bool { - ch.is_ascii_alphabetic() || ch == 'μ' || compatibility_unit_decomposition(ch).is_some() + ch.is_ascii_alphabetic() || ch == 'μ' || is_compatibility_unit_presentation(ch) } -fn roman_unit_chain_continues_before(ctx: &RuleContext) -> bool { +pub(crate) fn roman_unit_chain_continues_before(ctx: &RuleContext) -> bool { ctx.index >= 2 && ctx.word_chars.get(ctx.index - 1) == Some(&'/') && ctx @@ -1406,6 +1406,7 @@ mod tests { #[rstest::rstest] #[case::milligram_per_decilitre("160㎎/㎗", "⠼⠁⠋⠚⠴⠍⠛⠸⠌⠙⠇⠲")] #[case::calorie_per_square_centimetre_per_minute("cal/㎠/min", "⠴⠉⠁⠇⠸⠌⠉⠍⠘⠼⠃⠸⠌⠍⠔⠲")] + #[case::kilogram_force_per_square_metre("kgf/㎡이", "⠴⠅⠛⠋⠸⠌⠍⠘⠼⠃⠕")] #[case::megahertz("96.7 ㎒", "⠼⠊⠋⠲⠛⠀⠴⠠⠍⠠⠓⠵⠲")] #[case::kilometres_per_hour("80 ㎞/시", "⠼⠓⠚⠀⠴⠅⠍⠲⠸⠌⠠⠕")] fn preserves_pdf_unit_examples(#[case] input: &str, #[case] expected: &str) { From dc0a92d89df1611403fe89634bd58681b22739a9 Mon Sep 17 00:00:00 2001 From: devfive Date: Wed, 23 Sep 2026 16:38:14 +0900 Subject: [PATCH 057/132] Write chemical formulas and reactions the way science articles 1-8 do A science module parses formulas into items and encodes them: element capitals and the article 4 capital phrase, number and charge scripts (2), isotopes with the element first (3), electron configurations, electron dots (16) and genotypes (23). ChemicalFormulaRule places a formula in a Korean sentence: a single term takes the Roman indicator and closes per article 7-6, an expression with operators stands apart with two blanks (6). LaTeX chemistry in a Korean sentence now follows this layout instead of the maths spacing. A lone element with a subscript of 0 or 1 (V0, P1) stays a variable, since a single atom is written without a count. These files register and route the rule together, so they land as one unit. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/braillify/src/encoder.rs | 6 + libs/braillify/src/lib.rs | 64 +- libs/braillify/src/rules/context.rs | 5 + libs/braillify/src/rules/mod.rs | 1 + libs/braillify/src/rules/science/elements.rs | 75 + libs/braillify/src/rules/science/formula.rs | 1216 +++++++++++++++++ libs/braillify/src/rules/science/genotype.rs | 89 ++ libs/braillify/src/rules/science/mod.rs | 5 + .../src/rules/token_rules/chemical_formula.rs | 339 +++++ libs/braillify/src/rules/token_rules/mod.rs | 1 + 10 files changed, 1785 insertions(+), 16 deletions(-) create mode 100644 libs/braillify/src/rules/science/elements.rs create mode 100644 libs/braillify/src/rules/science/formula.rs create mode 100644 libs/braillify/src/rules/science/genotype.rs create mode 100644 libs/braillify/src/rules/science/mod.rs create mode 100644 libs/braillify/src/rules/token_rules/chemical_formula.rs diff --git a/libs/braillify/src/encoder.rs b/libs/braillify/src/encoder.rs index b65520b8..08d1c384 100644 --- a/libs/braillify/src/encoder.rs +++ b/libs/braillify/src/encoder.rs @@ -150,6 +150,9 @@ impl Encoder { token_engine.register(Box::new( rules::token_rules::cell_notation::CellNotationRule, )); + token_engine.register(Box::new( + rules::token_rules::chemical_formula::ChemicalFormulaRule, + )); token_engine.register(Box::new( rules::token_rules::uppercase_passage::UppercasePassageRule, )); @@ -257,6 +260,7 @@ impl Encoder { let mut ir = rules::token::DocumentIR::parse(text, self.english_indicator); ir.state.matrix_context_active = self.matrix_context_active; ir.state.math_mode_active = self.math_mode_active; + ir.state.korean_context_active = self.default_mode == Some(EncodingMode::Korean); ir.state.jamo_spans = trace.is_some().then(Box::::default); if let Some(mode) = self.default_mode @@ -339,6 +343,8 @@ impl Encoder { // contains `-`, `(`, `,`, `.` is NOT blocked (that over-broad reading // of the math detector would swallow `child-ish-ly`, `with(er)`, …). && !crate::rules::english_ueb::is_math_owned(text) + // 과학 제4·7항 — 화학식은 영어 낱말이 아니다. + && !crate::rules::science::formula::owns_text(text) { let encoded = if trace.is_some() { crate::rules::english_ueb::try_encode_traced(text) diff --git a/libs/braillify/src/lib.rs b/libs/braillify/src/lib.rs index dbbb3761..499c1d7f 100644 --- a/libs/braillify/src/lib.rs +++ b/libs/braillify/src/lib.rs @@ -461,6 +461,9 @@ fn normalize_pure_roman_compatibility_units<'a>(text: Cow<'a, str>) -> Cow<'a, s /// zero-width marks U+200B–U+200D and U+FEFF carry no print at all, so they are /// dropped like the soft hyphen. /// +/// U+212B ANGSTROM SIGN is the canonical equivalent (NFC) of `Å`, the unit +/// symbol of 제69항 [붙임 2] and 과학 제30항. +/// /// U+FF1A `:` and U+FF03 `#` are excluded: the standard gives those fullwidth /// glyphs their own meanings — the 옛한글 장음 표시 of 제27항 and the 기수 기호 of /// 수학 제65항 — so they are not print variants of ASCII `:` and `#`. @@ -479,7 +482,15 @@ fn parenthesized_number_expansion(c: char) -> Option { fn may_normalize_print_variant(c: char) -> bool { matches!( c, - '\u{02DA}' | '\u{2010}' | '\u{2011}' | '\u{2043}' | '\u{00AD}' | '\u{00B0}' | '²' | '³' + '\u{02DA}' + | '\u{2010}' + | '\u{2011}' + | '\u{2043}' + | '\u{00AD}' + | '\u{00B0}' + | '²' + | '³' + | '\u{212B}' ) || is_foldable_fullwidth(c) || parenthesized_number_expansion(c).is_some() || matches!( @@ -563,6 +574,7 @@ fn normalize_print_variants<'a>(text: Cow<'a, str>) -> Cow<'a, str> { out.push('\u{00B7}'); } '\u{2A2F}' => out.push('\u{00D7}'), + '\u{212B}' => out.push('\u{00C5}'), _ if parenthesized_number_expansion(ch).is_some() => { out.push_str(&parenthesized_number_expansion(ch).unwrap_or_default()); } @@ -1066,20 +1078,14 @@ fn decompose_accented_latin<'a>(text: Cow<'a, str>) -> Cow<'a, str> { /// 제37항 — 입력이 (공백을 제외하고) 전부 ASCII 로마자(알파벳)로만 이루어진 /// "고립된 로마자 구간"인지 판별한다. 이런 입력은 국어 점자 문맥(context:korean)에서 /// 로마자표 ⠴ … 종료표 ⠲로 감싼다. `%p`(제69항 단위표)처럼 비알파벳 기호가 섞인 -/// 입력은 로마자 구간이 아니므로 제외된다. +/// 입력은 로마자 구간이 아니므로 제외된다. 그리스 문자만으로 된 입력도 국어 문장 +/// 안에서는 로마자표와 종료표로 감싼다(제31항). 로마자와 섞인 `μm` 은 단위 +/// 기호(제69항 [붙임 1])라 따로 적는다. fn is_isolated_roman_section(text: &str) -> bool { - let mut has_letter = false; - for ch in text.chars() { - if ch == ' ' { - continue; - } - if ch.is_ascii_alphabetic() { - has_letter = true; - } else { - return false; - } - } - has_letter + let letters: Vec = text.chars().filter(|ch| *ch != ' ').collect(); + !letters.is_empty() + && (letters.iter().all(char::is_ascii_alphabetic) + || letters.iter().all(|ch| matches!(ch, 'Α'..='Ω' | 'α'..='ω'))) } /// Encode text to braille with explicit options. @@ -1123,6 +1129,17 @@ fn encode_with_options_traced( // N개 한글 음절을 cross-word 묶음으로 wrap. sentinel은 symbol_shortcut에서 // braille marker (⠠⠤/⠤⠄)로 emit된다. let normalization_triggers = NormalizationTriggers::scan(text); + // 과학 제4·7항 — 화학식은 로마자 낱말도 수식도 아니다. 영어·수학 경로와 글꼴 + // 정규화를 건너뛰어, 토큰 단계의 화학식 규칙이 강조(제7항 5)까지 그대로 본다. + let chemistry = + options.default_mode.is_none() && crate::rules::science::formula::owns_text(text); + // 한글 제69항 — 한글 없이 단위 기호 글자(`㎜Hg`, `㎾h`)로 적힌 글은 로마자 낱말이 + // 아니라 단위다. 정규화가 글자를 `mm`·`kW` 로 풀기 전에 그 신호를 잡아 둔다. + let unit_glyphs = options.default_mode.is_none() + && !text.chars().any(crate::utils::is_korean_char) + && text + .chars() + .any(crate::rules::korean::rule_69::is_compatibility_unit_presentation); // Content-routed English must be considered before math normalization. The // legacy math path decomposes accented Latin for Korean math 제65항, which turns // UEB §4.2 modified letters (`Rhône`, `Hwǣr`) into combining-mark sequences and @@ -1131,6 +1148,8 @@ fn encode_with_options_traced( // through the UEB engine here; ambiguous letterless/single-accent inputs remain // with the legacy Korean/math defaults because `is_ueb_eligible` rejects them. if options.default_mode.is_none() + && !chemistry + && !unit_glyphs && !text.chars().any(crate::utils::is_korean_char) && crate::rules::english_ueb::is_ueb_eligible(text) && !crate::rules::english_ueb::is_math_owned(text) @@ -1159,7 +1178,7 @@ fn encode_with_options_traced( mark_trace_path(&mut trace, TracePath::EnglishUeb); return Ok(bytes); } - let normalized_text = if normalization_triggers.has_math_alphanumeric { + let normalized_text = if normalization_triggers.has_math_alphanumeric && !chemistry { normalize_math_alphanumeric_string(text) } else { Cow::Borrowed(text) @@ -1303,6 +1322,7 @@ fn encode_with_options_traced( // token pipeline sees each space-separated word independently and can mark // variables/operators as UEB grade-1 text instead of one math expression. let default_math_owned = options.default_mode.is_none() + && !chemistry && default_math_expression_needs_whole_route(text) && !text.chars().any(crate::utils::is_korean_char); if matches!(options.default_mode, Some(EncodingMode::Math)) || default_math_owned { @@ -1430,13 +1450,23 @@ fn encode_with_options_traced( // `EncodingMode::English` 입력도 동일한 로마자 구간이므로 같은 처리를 받는다. // `%p`(제69항 단위표)처럼 비알파벳이 섞인 입력은 제외된다. let wrap_roman_section = matches!(options.default_mode, Some(EncodingMode::English)) - || (matches!(options.default_mode, Some(EncodingMode::Korean)) + || ((unit_glyphs || matches!(options.default_mode, Some(EncodingMode::Korean))) && is_isolated_roman_section(text)); if wrap_roman_section && !result.is_empty() { result.insert(0, 52); result.push(50); if let Some(sink) = trace { sink.shift_output(1); + let end = result.len() as u32; + for output in [0..1, end - 1..end] { + sink.push(TraceEvent { + rule: RuleId::emitter(EmitterRule::RomanSectionMarker), + outcome: RuleOutcome::Consumed, + token_index: 0, + word_chars: 0..0, + output, + }); + } } } Ok(result) @@ -3406,6 +3436,7 @@ mod coverage_targeted_tests { #[case::word("but", true)] #[case::phrase_with_spaces("Table of Contents", true)] #[case::percent_unit("%p", false)] + #[case::greek_unit("Ω", true)] #[case::has_digit("abc123", false)] #[case::empty("", false)] #[case::only_space(" ", false)] @@ -3785,6 +3816,7 @@ mod print_variant_fold_coverage { #[case::hyphenation_point("\u{2027}", "\u{00B7}")] #[case::one_dot_leader("\u{2024}", "\u{00B7}")] #[case::vector_cross("\u{2A2F}", "\u{00D7}")] + #[case::angstrom_sign("\u{212B}", "\u{00C5}")] #[case::parenthesised_five("\u{2478}", "(5)")] #[case::parenthesised_twenty("\u{2487}", "(20)")] #[case::wave_dash("\u{301C}", "~")] diff --git a/libs/braillify/src/rules/context.rs b/libs/braillify/src/rules/context.rs index 3654f298..1494c54e 100644 --- a/libs/braillify/src/rules/context.rs +++ b/libs/braillify/src/rules/context.rs @@ -111,6 +111,10 @@ pub struct EncoderState { /// Explicit math mode (`context = math` in fixtures/API options). /// Keeps parentheses in math form even when their contents include Hangul. pub math_mode_active: bool, + /// Explicit Korean context (`context = korean`): the text sits in a Korean + /// sentence even when it carries no Hangul, as the unit table of 과학 제30항 + /// does (`mH₂O` → ⠴⠍⠠⠓⠰⠼⠃⠠⠕). + pub korean_context_active: bool, /// 짝맞춤 작은따옴표(`‘…’`) 추적: `‘`를 만나면 +1, 닫음 `’`로 -1. /// 0보다 크면 현재 위치는 paired closing 위치이므로 `’`를 `⠴⠄`로 emit. /// 0이면 standalone apostrophe로 `⠄` 한 셀만 emit. (PDF 제61항) @@ -144,6 +148,7 @@ impl EncoderState { doc_summary: DocumentSummary::default(), matrix_context_active: false, math_mode_active: false, + korean_context_active: false, unmatched_open_single_quotes: 0, jamo_spans: None, } diff --git a/libs/braillify/src/rules/mod.rs b/libs/braillify/src/rules/mod.rs index 674639dd..851702d1 100644 --- a/libs/braillify/src/rules/mod.rs +++ b/libs/braillify/src/rules/mod.rs @@ -36,6 +36,7 @@ pub mod traits; pub mod english_ueb; // 통일영어점자 규정 (Unified English Braille) pub mod korean; // 한글 점자 규정 (Korean Braille rules) pub mod math; // 수학 점자 규정 (Math Braille rules) +pub mod science; // 과학 점자 규정 (Science Braille rules) /// Metadata identifying a braille rule and its source in the standard. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/libs/braillify/src/rules/science/elements.rs b/libs/braillify/src/rules/science/elements.rs new file mode 100644 index 00000000..ff352073 --- /dev/null +++ b/libs/braillify/src/rules/science/elements.rs @@ -0,0 +1,75 @@ +//! 원소 기호 — 과학 점자의 모든 규칙이 함께 쓰는 단일 목록. +//! +//! 한 글자짜리 원소 기호는 수학 변수와 글자가 겹치므로(`P`, `V`, `B`, `C`), 어떤 +//! 대문자가 원소인지는 이 목록으로만 판정한다. 화학식이라는 신호(첨자, 반응 +//! 화살표 등)를 따로 보는 것은 각 규칙의 몫이다. + +use phf::{Set, phf_set}; + +/// 주기율표의 118개 원소 기호. +static ELEMENTS: Set<&'static str> = phf_set! { + "H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne", "Na", "Mg", "Al", "Si", "P", "S", "Cl", + "Ar", "K", "Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", "Cu", "Zn", "Ga", "Ge", "As", + "Se", "Br", "Kr", "Rb", "Sr", "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd", "In", + "Sn", "Sb", "Te", "I", "Xe", "Cs", "Ba", "La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd", "Tb", + "Dy", "Ho", "Er", "Tm", "Yb", "Lu", "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg", "Tl", + "Pb", "Bi", "Po", "At", "Rn", "Fr", "Ra", "Ac", "Th", "Pa", "U", "Np", "Pu", "Am", "Cm", "Bk", + "Cf", "Es", "Fm", "Md", "No", "Lr", "Rf", "Db", "Sg", "Bh", "Hs", "Mt", "Ds", "Rg", "Cn", "Nh", + "Fl", "Mc", "Lv", "Ts", "Og", +}; + +/// `symbol` 이 원소 기호인가. +pub fn is_element(symbol: &str) -> bool { + ELEMENTS.contains(symbol) +} + +/// 대문자 하나가 그대로 원소 기호인가. 과학 제4항 대문자 구절표의 대상이다. +pub fn is_single_letter_element(letter: char) -> bool { + let mut buf = [0u8; 4]; + ELEMENTS.contains(letter.encode_utf8(&mut buf)) +} + +/// 대문자와 소문자가 이어진 두 글자 원소 기호인가(`Na`, `Cl`). +pub fn is_two_letter_element(upper: char, lower: char) -> bool { + upper.is_ascii_uppercase() + && lower.is_ascii_lowercase() + && ELEMENTS.contains(format!("{upper}{lower}").as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[rstest::rstest] + #[case::hydrogen("H", true)] + #[case::sodium("Na", true)] + #[case::oganesson("Og", true)] + #[case::not_an_element("A", false)] + #[case::lowercase("na", false)] + fn knows_the_periodic_table(#[case] symbol: &str, #[case] expected: bool) { + assert_eq!(is_element(symbol), expected); + } + + #[rstest::rstest] + #[case::hydrogen('H', true)] + #[case::uranium('U', true)] + #[case::rest_group('R', false)] + #[case::lowercase('h', false)] + fn knows_single_letter_elements(#[case] letter: char, #[case] expected: bool) { + assert_eq!(is_single_letter_element(letter), expected); + } + + #[rstest::rstest] + #[case::chlorine('C', 'l', true)] + #[case::cobalt('C', 'o', true)] + #[case::not_an_element('C', 'x', false)] + #[case::wrong_case('c', 'l', false)] + fn knows_two_letter_elements(#[case] upper: char, #[case] lower: char, #[case] expected: bool) { + assert_eq!(is_two_letter_element(upper, lower), expected); + } + + #[test] + fn holds_every_element_once() { + assert_eq!(ELEMENTS.len(), 118); + } +} diff --git a/libs/braillify/src/rules/science/formula.rs b/libs/braillify/src/rules/science/formula.rs new file mode 100644 index 00000000..37fa757b --- /dev/null +++ b/libs/braillify/src/rules/science/formula.rs @@ -0,0 +1,1216 @@ +//! 과학 제1~8·18항 — 화학식과 화학 반응식. +//! +//! 원소 기호는 모두 1급 점자로 적고(제4항 1) 원소 기호마다 대문자표를 붙인다 +//! (제7항 1). 로마자 하나로 된 원소 기호가 셋 이상 이어지면 대문자 구절표 +//! ⠠⠠⠠ 로 묶고, 마지막 한 글자 원소 기호와 그 첨자 뒤에 대문자 종료표 ⠠⠄ 를 +//! 적는다(제4항, [붙임 1]). 구절 안에서 숫자 뒤에 붙는 H·B·C·F·I 앞에는 ⠐ 을 +//! 적는다(제5항). + +use crate::english::encode_english; +use crate::number::encode_number; +use crate::rules::english_ueb::rule_9::decode_styled; +use crate::rules::english_ueb::token::Typeform; +use crate::unicode::decode_unicode; + +use super::elements::{is_single_letter_element, is_two_letter_element}; + +/// 상태 기호. 과학 제18항 3 — 한글 소괄호로 묶는다. +const STATES: &[&str] = &["s", "l", "g", "aq"]; + +/// 과학 제7항 5 — 화학식 안에서 강조된 문자는 통일영어점자 §9 에 따른다. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Style { + Italic, + Bold, + Underline, +} + +impl Style { + fn lead(self) -> u8 { + decode_unicode(match self { + Style::Italic => '⠨', + Style::Bold => '⠘', + Style::Underline => '⠸', + }) + } +} + +/// 식을 이루는 낱낱. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Item { + /// 원소 기호, 또는 원소가 아닌 로마자 대문자(`R`, `NAD` 의 `A`·`D`). + Capital { + symbol: String, + element: bool, + style: Option