blitz-pdf 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/THIRD_PARTY_NOTICES.md +5 -2
- package/docs/layout-regressions.md +65 -0
- package/docs/pagination-regressions.md +89 -0
- package/package.json +2 -1
- package/vendor/README.md +35 -0
- package/vendor/blitz-dom/Cargo.toml +113 -0
- package/vendor/blitz-dom/LICENSE-APACHE +176 -0
- package/vendor/blitz-dom/LICENSE-MIT +23 -0
- package/vendor/blitz-dom/assets/default.css +1142 -0
- package/vendor/blitz-dom/assets/moz-bullet-font.otf +0 -0
- package/vendor/blitz-dom/src/accessibility.rs +255 -0
- package/vendor/blitz-dom/src/config.rs +97 -0
- package/vendor/blitz-dom/src/cssom.rs +971 -0
- package/vendor/blitz-dom/src/debug.rs +206 -0
- package/vendor/blitz-dom/src/document.rs +3395 -0
- package/vendor/blitz-dom/src/events/driver.rs +427 -0
- package/vendor/blitz-dom/src/events/focus.rs +40 -0
- package/vendor/blitz-dom/src/events/ime.rs +31 -0
- package/vendor/blitz-dom/src/events/keyboard.rs +174 -0
- package/vendor/blitz-dom/src/events/mod.rs +315 -0
- package/vendor/blitz-dom/src/events/pointer.rs +849 -0
- package/vendor/blitz-dom/src/font_metrics.rs +177 -0
- package/vendor/blitz-dom/src/form.rs +463 -0
- package/vendor/blitz-dom/src/html.rs +38 -0
- package/vendor/blitz-dom/src/iframe.rs +186 -0
- package/vendor/blitz-dom/src/layout/construct.rs +1328 -0
- package/vendor/blitz-dom/src/layout/damage.rs +642 -0
- package/vendor/blitz-dom/src/layout/inline.rs +1160 -0
- package/vendor/blitz-dom/src/layout/list.rs +237 -0
- package/vendor/blitz-dom/src/layout/mod.rs +969 -0
- package/vendor/blitz-dom/src/layout/paint_tree.rs +462 -0
- package/vendor/blitz-dom/src/layout/replaced.rs +439 -0
- package/vendor/blitz-dom/src/layout/table.rs +967 -0
- package/vendor/blitz-dom/src/layout/text_transform.rs +979 -0
- package/vendor/blitz-dom/src/layout/writing_mode.rs +437 -0
- package/vendor/blitz-dom/src/lib.rs +160 -0
- package/vendor/blitz-dom/src/mutator.rs +1818 -0
- package/vendor/blitz-dom/src/net.rs +649 -0
- package/vendor/blitz-dom/src/node/attributes.rs +60 -0
- package/vendor/blitz-dom/src/node/custom_widget.rs +178 -0
- package/vendor/blitz-dom/src/node/element.rs +1106 -0
- package/vendor/blitz-dom/src/node/mod.rs +30 -0
- package/vendor/blitz-dom/src/node/node.rs +2036 -0
- package/vendor/blitz-dom/src/node/scrollbar.rs +237 -0
- package/vendor/blitz-dom/src/node/serialize.rs +225 -0
- package/vendor/blitz-dom/src/node/stylo_data.rs +142 -0
- package/vendor/blitz-dom/src/node/svg.rs +265 -0
- package/vendor/blitz-dom/src/node/text.rs +804 -0
- package/vendor/blitz-dom/src/query_selector.rs +275 -0
- package/vendor/blitz-dom/src/resolve.rs +474 -0
- package/vendor/blitz-dom/src/resolved_style.rs +797 -0
- package/vendor/blitz-dom/src/scrolling.rs +780 -0
- package/vendor/blitz-dom/src/selection.rs +122 -0
- package/vendor/blitz-dom/src/stylo.rs +1518 -0
- package/vendor/blitz-dom/src/stylo_device.rs +88 -0
- package/vendor/blitz-dom/src/stylo_to_cursor_icon.rs +49 -0
- package/vendor/blitz-dom/src/stylo_to_kurbo.rs +129 -0
- package/vendor/blitz-dom/src/stylo_to_parley.rs +655 -0
- package/vendor/blitz-dom/src/traversal.rs +600 -0
- package/vendor/blitz-dom/src/tree.rs +136 -0
- package/vendor/blitz-dom/src/url.rs +63 -0
- package/vendor/blitz-dom/src/util.rs +256 -0
- package/wasm/blitz_pdf_bg.wasm +0 -0
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
//! Case, mathematical italic, full-width and full-size kana mapping for the CSS `text-transform`
|
|
2
|
+
//! property.
|
|
3
|
+
//!
|
|
4
|
+
//! <https://drafts.csswg.org/css-text/#text-transform-property>
|
|
5
|
+
|
|
6
|
+
#[cfg(feature = "text-transform-icu")]
|
|
7
|
+
use icu_casemap::options::{LeadingAdjustment, TitlecaseOptions, TrailingCase};
|
|
8
|
+
#[cfg(feature = "text-transform-icu")]
|
|
9
|
+
use icu_casemap::{CaseMapper, CaseMapperBorrowed, TitlecaseMapper, TitlecaseMapperBorrowed};
|
|
10
|
+
use icu_locale_core::LanguageIdentifier;
|
|
11
|
+
use icu_properties::props::{GeneralCategory, GeneralCategoryGroup};
|
|
12
|
+
use icu_properties::{CodePointMapData, CodePointMapDataBorrowed};
|
|
13
|
+
use icu_segmenter::{WordSegmenter, WordSegmenterBorrowed, options::WordBreakInvariantOptions};
|
|
14
|
+
use parley::{Brush, TreeBuilder};
|
|
15
|
+
use style::computed_values::white_space_collapse::T as WhiteSpaceCollapse;
|
|
16
|
+
use style::properties::ComputedValues;
|
|
17
|
+
use style::values::computed::TextTransform;
|
|
18
|
+
use style::values::specified::text::TextTransformCase;
|
|
19
|
+
#[cfg(feature = "text-transform-icu")]
|
|
20
|
+
use writeable::Writeable;
|
|
21
|
+
|
|
22
|
+
#[cfg(feature = "text-transform-icu")]
|
|
23
|
+
const CASE_MAPPER: CaseMapperBorrowed<'static> = CaseMapper::new();
|
|
24
|
+
#[cfg(feature = "text-transform-icu")]
|
|
25
|
+
const TITLECASE_MAPPER: TitlecaseMapperBorrowed<'static> = TitlecaseMapper::new();
|
|
26
|
+
const WORD_SEGMENTER: WordSegmenterBorrowed<'static> =
|
|
27
|
+
WordSegmenter::new_for_non_complex_scripts(WordBreakInvariantOptions::default());
|
|
28
|
+
const GENERAL_CATEGORY: CodePointMapDataBorrowed<'static, GeneralCategory> =
|
|
29
|
+
CodePointMapData::<GeneralCategory>::new();
|
|
30
|
+
|
|
31
|
+
const WIDTH_TRANSFORMS: TextTransform =
|
|
32
|
+
TextTransform::FULL_WIDTH.union(TextTransform::FULL_SIZE_KANA);
|
|
33
|
+
|
|
34
|
+
/// The maximum number of bytes of preceding text kept as context for finding word boundaries.
|
|
35
|
+
const MAX_CONTEXT_LEN: usize = 32;
|
|
36
|
+
|
|
37
|
+
/// The text transforms (and language) for an element's text content.
|
|
38
|
+
#[derive(Clone, Debug, PartialEq)]
|
|
39
|
+
pub(crate) struct CaseTransform {
|
|
40
|
+
text_transform: TextTransform,
|
|
41
|
+
/// Determines which spaces `full-width` maps.
|
|
42
|
+
white_space_collapse: WhiteSpaceCollapse,
|
|
43
|
+
lang: LanguageIdentifier,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
impl CaseTransform {
|
|
47
|
+
pub(crate) const NONE: Self = Self {
|
|
48
|
+
text_transform: TextTransform::NONE,
|
|
49
|
+
white_space_collapse: WhiteSpaceCollapse::Collapse,
|
|
50
|
+
lang: LanguageIdentifier::UNKNOWN,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
pub(crate) fn from_style(style: &ComputedValues) -> Self {
|
|
54
|
+
let text_transform = style.clone_text_transform();
|
|
55
|
+
if text_transform.is_empty() {
|
|
56
|
+
return Self::NONE;
|
|
57
|
+
}
|
|
58
|
+
let white_space_collapse = style.clone_white_space_collapse();
|
|
59
|
+
let lang = match text_transform.case() {
|
|
60
|
+
TextTransformCase::None | TextTransformCase::MathAuto => LanguageIdentifier::UNKNOWN,
|
|
61
|
+
_ => LanguageIdentifier::try_from_str(&style.get_font()._x_lang.0)
|
|
62
|
+
.map(casing_language)
|
|
63
|
+
.unwrap_or(LanguageIdentifier::UNKNOWN),
|
|
64
|
+
};
|
|
65
|
+
Self {
|
|
66
|
+
text_transform,
|
|
67
|
+
white_space_collapse,
|
|
68
|
+
lang,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Whether the language has case mappings for ASCII characters that differ from the
|
|
73
|
+
/// default ones (`i` ↔ `İ` and `ı` ↔ `I`).
|
|
74
|
+
fn has_turkic_casing(&self) -> bool {
|
|
75
|
+
cfg!(feature = "text-transform-icu") && matches!(self.lang.language.as_str(), "tr" | "az")
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Language-specific case mapping rules don't apply if an explicit script subtag contradicts
|
|
80
|
+
/// the language's script (e.g. `tr-Cyrl`).
|
|
81
|
+
///
|
|
82
|
+
/// <https://drafts.csswg.org/css-text-3/#script-tagging>
|
|
83
|
+
fn casing_language(lang: LanguageIdentifier) -> LanguageIdentifier {
|
|
84
|
+
let Some(script) = lang.script else {
|
|
85
|
+
return lang;
|
|
86
|
+
};
|
|
87
|
+
// The languages with tailored case mappings in ICU4X.
|
|
88
|
+
let language_script = match lang.language.as_str() {
|
|
89
|
+
"az" | "lt" | "nl" | "tr" => "Latn",
|
|
90
|
+
"el" => "Grek",
|
|
91
|
+
"hy" => "Armn",
|
|
92
|
+
_ => return lang,
|
|
93
|
+
};
|
|
94
|
+
if script.as_str() == language_script {
|
|
95
|
+
lang
|
|
96
|
+
} else {
|
|
97
|
+
LanguageIdentifier::UNKNOWN
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// Applies text transforms to the sequence of text nodes in an inline formatting context.
|
|
102
|
+
///
|
|
103
|
+
/// `capitalize` operates on words, which may span multiple text nodes, so the text already
|
|
104
|
+
/// pushed to the [`TreeBuilder`] is used as context for word segmentation. Only look-behind
|
|
105
|
+
/// is needed: whether a word boundary precedes a letter never depends on the text that
|
|
106
|
+
/// follows it.
|
|
107
|
+
#[derive(Default)]
|
|
108
|
+
pub(crate) struct TextTransformer {
|
|
109
|
+
/// The offset in the builder's text of the last forced word boundary.
|
|
110
|
+
context_start: usize,
|
|
111
|
+
/// Reused buffer for the context followed by the text to be capitalized.
|
|
112
|
+
segmentation_buffer: String,
|
|
113
|
+
/// Reused buffer for the transformed text.
|
|
114
|
+
output: String,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
impl TextTransformer {
|
|
118
|
+
/// Forces a word boundary (e.g. at an atomic inline or a forced line break).
|
|
119
|
+
pub(crate) fn word_break<B: Brush>(&mut self, builder: &TreeBuilder<'_, B>) {
|
|
120
|
+
self.context_start = builder.text().len();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Transforms the content of a text node that is about to be pushed to `builder`.
|
|
124
|
+
#[inline]
|
|
125
|
+
pub(crate) fn transform<'a, B: Brush>(
|
|
126
|
+
&'a mut self,
|
|
127
|
+
text: &'a str,
|
|
128
|
+
transform: &CaseTransform,
|
|
129
|
+
builder: &TreeBuilder<'_, B>,
|
|
130
|
+
) -> &'a str {
|
|
131
|
+
if transform.text_transform.is_empty() {
|
|
132
|
+
return text;
|
|
133
|
+
}
|
|
134
|
+
self.transform_nonempty(text, transform, builder)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fn transform_nonempty<'a, B: Brush>(
|
|
138
|
+
&'a mut self,
|
|
139
|
+
text: &'a str,
|
|
140
|
+
transform: &CaseTransform,
|
|
141
|
+
builder: &TreeBuilder<'_, B>,
|
|
142
|
+
) -> &'a str {
|
|
143
|
+
let case = transform.text_transform.case();
|
|
144
|
+
if text.is_ascii()
|
|
145
|
+
&& !transform.has_turkic_casing()
|
|
146
|
+
&& !transform.text_transform.contains(TextTransform::FULL_WIDTH)
|
|
147
|
+
{
|
|
148
|
+
match case {
|
|
149
|
+
TextTransformCase::Uppercase => {
|
|
150
|
+
return map_ascii(
|
|
151
|
+
text,
|
|
152
|
+
&mut self.output,
|
|
153
|
+
u8::is_ascii_lowercase,
|
|
154
|
+
str::make_ascii_uppercase,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
TextTransformCase::Lowercase => {
|
|
158
|
+
return map_ascii(
|
|
159
|
+
text,
|
|
160
|
+
&mut self.output,
|
|
161
|
+
u8::is_ascii_uppercase,
|
|
162
|
+
str::make_ascii_lowercase,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
TextTransformCase::None => return text,
|
|
166
|
+
_ => {}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let mut output = OutputSink::new(text, &mut self.output);
|
|
171
|
+
output.width = transform.text_transform & WIDTH_TRANSFORMS;
|
|
172
|
+
output.white_space_collapse = transform.white_space_collapse;
|
|
173
|
+
match case {
|
|
174
|
+
TextTransformCase::None => output.push_str(text),
|
|
175
|
+
TextTransformCase::Uppercase => uppercase(text, &transform.lang, &mut output),
|
|
176
|
+
TextTransformCase::Lowercase => lowercase(text, &transform.lang, &mut output),
|
|
177
|
+
TextTransformCase::MathAuto => math_auto(text, &mut output),
|
|
178
|
+
TextTransformCase::Capitalize => {
|
|
179
|
+
// Pending (collapsed) whitespace always ends the preceding word, so no
|
|
180
|
+
// context is needed.
|
|
181
|
+
let context = if builder.has_pending_whitespace() {
|
|
182
|
+
""
|
|
183
|
+
} else {
|
|
184
|
+
let preceding = &builder.text()[self.context_start..];
|
|
185
|
+
let start = ceil_char_boundary(
|
|
186
|
+
preceding,
|
|
187
|
+
preceding.len().saturating_sub(MAX_CONTEXT_LEN),
|
|
188
|
+
);
|
|
189
|
+
&preceding[start..]
|
|
190
|
+
};
|
|
191
|
+
capitalize(
|
|
192
|
+
context,
|
|
193
|
+
text,
|
|
194
|
+
&transform.lang,
|
|
195
|
+
&mut self.segmentation_buffer,
|
|
196
|
+
&mut output,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
output.finish()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Maps single-character text nodes to mathematical italic characters.
|
|
205
|
+
/// <https://w3c.github.io/mathml-core/#italic-mappings>
|
|
206
|
+
fn math_auto(text: &str, output: &mut OutputSink<'_>) {
|
|
207
|
+
let mut chars = text.chars();
|
|
208
|
+
let Some(c) = chars.next().filter(|_| chars.next().is_none()) else {
|
|
209
|
+
output.push_str(text);
|
|
210
|
+
return;
|
|
211
|
+
};
|
|
212
|
+
let codepoint = match c {
|
|
213
|
+
'A'..='Z' => c as u32 + 0x1D3F3,
|
|
214
|
+
'h' => 0x210E,
|
|
215
|
+
'a'..='z' => c as u32 + 0x1D3ED,
|
|
216
|
+
'\u{131}' => 0x1D6A4,
|
|
217
|
+
'\u{237}' => 0x1D6A5,
|
|
218
|
+
'\u{391}'..='\u{3A1}' | '\u{3A3}'..='\u{3A9}' => c as u32 + 0x1D351,
|
|
219
|
+
'\u{3B1}'..='\u{3C9}' => c as u32 + 0x1D34B,
|
|
220
|
+
'\u{3F4}' => 0x1D6F3,
|
|
221
|
+
'\u{2207}' => 0x1D6FB,
|
|
222
|
+
'\u{2202}' => 0x1D715,
|
|
223
|
+
'\u{3F5}' => 0x1D716,
|
|
224
|
+
'\u{3D1}' => 0x1D717,
|
|
225
|
+
'\u{3F0}' => 0x1D718,
|
|
226
|
+
'\u{3D5}' => 0x1D719,
|
|
227
|
+
'\u{3F1}' => 0x1D71A,
|
|
228
|
+
'\u{3D6}' => 0x1D71B,
|
|
229
|
+
_ => {
|
|
230
|
+
output.push_str(text);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
let mapped = char::from_u32(codepoint).unwrap();
|
|
235
|
+
output.push_str(mapped.encode_utf8(&mut [0; 4]));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// Whether Parley collapses `c` with the given `white-space-collapse`.
|
|
239
|
+
fn is_collapsible(white_space_collapse: WhiteSpaceCollapse, c: char) -> bool {
|
|
240
|
+
match white_space_collapse {
|
|
241
|
+
WhiteSpaceCollapse::Collapse => c.is_ascii_whitespace(),
|
|
242
|
+
WhiteSpaceCollapse::PreserveBreaks => matches!(c, ' ' | '\t'),
|
|
243
|
+
WhiteSpaceCollapse::Preserve | WhiteSpaceCollapse::BreakSpaces => false,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/// Maps `c` to its full-width form: the reverse of its `<wide>` decomposition mapping or its
|
|
248
|
+
/// `<narrow>` decomposition mapping, if any.
|
|
249
|
+
/// <https://drafts.csswg.org/css-text-3/#text-transform-mapping>
|
|
250
|
+
pub fn full_width(c: char) -> char {
|
|
251
|
+
const HALFWIDTH_KATAKANA: [char; 63] = [
|
|
252
|
+
'\u{3002}', '\u{300C}', '\u{300D}', '\u{3001}', '\u{30FB}', '\u{30F2}', '\u{30A1}',
|
|
253
|
+
'\u{30A3}', '\u{30A5}', '\u{30A7}', '\u{30A9}', '\u{30E3}', '\u{30E5}', '\u{30E7}',
|
|
254
|
+
'\u{30C3}', '\u{30FC}', '\u{30A2}', '\u{30A4}', '\u{30A6}', '\u{30A8}', '\u{30AA}',
|
|
255
|
+
'\u{30AB}', '\u{30AD}', '\u{30AF}', '\u{30B1}', '\u{30B3}', '\u{30B5}', '\u{30B7}',
|
|
256
|
+
'\u{30B9}', '\u{30BB}', '\u{30BD}', '\u{30BF}', '\u{30C1}', '\u{30C4}', '\u{30C6}',
|
|
257
|
+
'\u{30C8}', '\u{30CA}', '\u{30CB}', '\u{30CC}', '\u{30CD}', '\u{30CE}', '\u{30CF}',
|
|
258
|
+
'\u{30D2}', '\u{30D5}', '\u{30D8}', '\u{30DB}', '\u{30DE}', '\u{30DF}', '\u{30E0}',
|
|
259
|
+
'\u{30E1}', '\u{30E2}', '\u{30E4}', '\u{30E6}', '\u{30E8}', '\u{30E9}', '\u{30EA}',
|
|
260
|
+
'\u{30EB}', '\u{30EC}', '\u{30ED}', '\u{30EF}', '\u{30F3}', '\u{3099}', '\u{309A}',
|
|
261
|
+
];
|
|
262
|
+
let codepoint = match c {
|
|
263
|
+
' ' => 0x3000,
|
|
264
|
+
'!'..='~' => c as u32 + 0xFEE0,
|
|
265
|
+
'\u{A2}' => 0xFFE0,
|
|
266
|
+
'\u{A3}' => 0xFFE1,
|
|
267
|
+
'\u{A5}' => 0xFFE5,
|
|
268
|
+
'\u{A6}' => 0xFFE4,
|
|
269
|
+
'\u{AC}' => 0xFFE2,
|
|
270
|
+
'\u{AF}' => 0xFFE3,
|
|
271
|
+
'\u{20A9}' => 0xFFE6,
|
|
272
|
+
'\u{2985}' => 0xFF5F,
|
|
273
|
+
'\u{2986}' => 0xFF60,
|
|
274
|
+
'\u{FF61}'..='\u{FF9F}' => return HALFWIDTH_KATAKANA[c as usize - 0xFF61],
|
|
275
|
+
// Halfwidth Hangul
|
|
276
|
+
'\u{FFA0}' => 0x3164,
|
|
277
|
+
'\u{FFA1}'..='\u{FFBE}' => c as u32 - 0xCE70,
|
|
278
|
+
'\u{FFC2}'..='\u{FFC7}' => c as u32 - 0xCE73,
|
|
279
|
+
'\u{FFCA}'..='\u{FFCF}' => c as u32 - 0xCE75,
|
|
280
|
+
'\u{FFD2}'..='\u{FFD7}' => c as u32 - 0xCE77,
|
|
281
|
+
'\u{FFDA}'..='\u{FFDC}' => c as u32 - 0xCE79,
|
|
282
|
+
'\u{FFE8}' => 0x2502,
|
|
283
|
+
'\u{FFE9}'..='\u{FFEC}' => c as u32 - 0xDE59,
|
|
284
|
+
'\u{FFED}' => 0x25A0,
|
|
285
|
+
'\u{FFEE}' => 0x25CB,
|
|
286
|
+
_ => return c,
|
|
287
|
+
};
|
|
288
|
+
char::from_u32(codepoint).unwrap()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/// Maps small kana to full-size kana.
|
|
292
|
+
/// <https://drafts.csswg.org/css-text-3/#small-kana>
|
|
293
|
+
pub fn full_size_kana(c: char) -> char {
|
|
294
|
+
const SMALL_KATAKANA_EXTENSIONS: [char; 16] = [
|
|
295
|
+
'\u{30AF}', '\u{30B7}', '\u{30B9}', '\u{30C8}', '\u{30CC}', '\u{30CF}', '\u{30D2}',
|
|
296
|
+
'\u{30D5}', '\u{30D8}', '\u{30DB}', '\u{30E0}', '\u{30E9}', '\u{30EA}', '\u{30EB}',
|
|
297
|
+
'\u{30EC}', '\u{30ED}',
|
|
298
|
+
];
|
|
299
|
+
let codepoint = match c {
|
|
300
|
+
'\u{3041}' | '\u{3043}' | '\u{3045}' | '\u{3047}' | '\u{3049}' | '\u{3063}'
|
|
301
|
+
| '\u{3083}' | '\u{3085}' | '\u{3087}' | '\u{308E}' | '\u{30A1}' | '\u{30A3}'
|
|
302
|
+
| '\u{30A5}' | '\u{30A7}' | '\u{30A9}' | '\u{30C3}' | '\u{30E3}' | '\u{30E5}'
|
|
303
|
+
| '\u{30E7}' | '\u{30EE}' => c as u32 + 1,
|
|
304
|
+
'\u{3095}' => 0x304B,
|
|
305
|
+
'\u{3096}' => 0x3051,
|
|
306
|
+
'\u{30F5}' => 0x30AB,
|
|
307
|
+
'\u{30F6}' => 0x30B1,
|
|
308
|
+
'\u{31F0}'..='\u{31FF}' => return SMALL_KATAKANA_EXTENSIONS[c as usize - 0x31F0],
|
|
309
|
+
'\u{FF67}'..='\u{FF6B}' => c as u32 + 10,
|
|
310
|
+
'\u{FF6C}' => 0xFF94,
|
|
311
|
+
'\u{FF6D}' => 0xFF95,
|
|
312
|
+
'\u{FF6E}' => 0xFF96,
|
|
313
|
+
'\u{FF6F}' => 0xFF82,
|
|
314
|
+
'\u{1B132}' => 0x3053,
|
|
315
|
+
'\u{1B150}'..='\u{1B152}' => c as u32 - 0x180C0,
|
|
316
|
+
'\u{1B155}' => 0x30B3,
|
|
317
|
+
'\u{1B164}'..='\u{1B167}' => c as u32 - 0x18074,
|
|
318
|
+
_ => return c,
|
|
319
|
+
};
|
|
320
|
+
char::from_u32(codepoint).unwrap()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/// Case maps ASCII `text`, borrowing it if no bytes need mapping and otherwise copying it into
|
|
324
|
+
/// `buffer`.
|
|
325
|
+
fn map_ascii<'a>(
|
|
326
|
+
text: &'a str,
|
|
327
|
+
buffer: &'a mut String,
|
|
328
|
+
needs_mapping: impl Fn(&u8) -> bool,
|
|
329
|
+
map: impl FnOnce(&mut str),
|
|
330
|
+
) -> &'a str {
|
|
331
|
+
let Some(first) = text.bytes().position(|b| needs_mapping(&b)) else {
|
|
332
|
+
return text;
|
|
333
|
+
};
|
|
334
|
+
buffer.clear();
|
|
335
|
+
buffer.push_str(text);
|
|
336
|
+
map(&mut buffer[first..]);
|
|
337
|
+
buffer
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/// Collects transformed text, borrowing the original text if it is unchanged and otherwise
|
|
341
|
+
/// copying it into a reused buffer from the first difference onwards.
|
|
342
|
+
struct OutputSink<'a> {
|
|
343
|
+
original: &'a str,
|
|
344
|
+
/// The length of the output so far if it is a prefix of `original`, or `None` if the output
|
|
345
|
+
/// has diverged and is in `buffer`.
|
|
346
|
+
unchanged_len: Option<usize>,
|
|
347
|
+
buffer: &'a mut String,
|
|
348
|
+
/// `full-width` and/or `full-size-kana`, applied to each pushed character.
|
|
349
|
+
width: TextTransform,
|
|
350
|
+
white_space_collapse: WhiteSpaceCollapse,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
impl<'a> OutputSink<'a> {
|
|
354
|
+
fn new(original: &'a str, buffer: &'a mut String) -> Self {
|
|
355
|
+
Self {
|
|
356
|
+
original,
|
|
357
|
+
unchanged_len: Some(0),
|
|
358
|
+
buffer,
|
|
359
|
+
width: TextTransform::NONE,
|
|
360
|
+
white_space_collapse: WhiteSpaceCollapse::Collapse,
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
fn push_str(&mut self, s: &str) {
|
|
365
|
+
if self.width.is_empty() {
|
|
366
|
+
self.push_unmapped(s);
|
|
367
|
+
} else {
|
|
368
|
+
s.chars().for_each(|c| self.push_char(c));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/// Pushes `c` after applying the `full-width` and `full-size-kana` mappings.
|
|
373
|
+
///
|
|
374
|
+
/// Text transforms apply after white space collapsing, so collapsible white space is left
|
|
375
|
+
/// for Parley to collapse.
|
|
376
|
+
// TODO: collapsed spaces should become U+3000 for `full-width`, which needs Parley support.
|
|
377
|
+
fn push_char(&mut self, mut c: char) {
|
|
378
|
+
if self.width.contains(TextTransform::FULL_WIDTH)
|
|
379
|
+
&& !is_collapsible(self.white_space_collapse, c)
|
|
380
|
+
{
|
|
381
|
+
c = full_width(c);
|
|
382
|
+
}
|
|
383
|
+
if self.width.contains(TextTransform::FULL_SIZE_KANA) {
|
|
384
|
+
c = full_size_kana(c);
|
|
385
|
+
}
|
|
386
|
+
self.push_unmapped(c.encode_utf8(&mut [0; 4]));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#[inline(always)]
|
|
390
|
+
fn push_unmapped(&mut self, s: &str) {
|
|
391
|
+
if let Some(len) = self.unchanged_len {
|
|
392
|
+
if self.original[len..].starts_with(s) {
|
|
393
|
+
self.unchanged_len = Some(len + s.len());
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
self.buffer.clear();
|
|
397
|
+
self.buffer.push_str(&self.original[..len]);
|
|
398
|
+
self.unchanged_len = None;
|
|
399
|
+
}
|
|
400
|
+
self.buffer.push_str(s);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
#[cfg(feature = "text-transform-icu")]
|
|
404
|
+
fn write(&mut self, writeable: impl Writeable) {
|
|
405
|
+
// Writing to an `OutputSink` is infallible.
|
|
406
|
+
let _ = writeable.write_to(self);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
fn finish(self) -> &'a str {
|
|
410
|
+
match self.unchanged_len {
|
|
411
|
+
Some(len) => &self.original[..len],
|
|
412
|
+
None => self.buffer,
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
#[cfg(feature = "text-transform-icu")]
|
|
418
|
+
impl std::fmt::Write for OutputSink<'_> {
|
|
419
|
+
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
|
420
|
+
self.push_str(s);
|
|
421
|
+
Ok(())
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/// Writes `text` to `output`, titlecasing the first typographic letter unit of each word and
|
|
426
|
+
/// leaving other characters unchanged. `context` is the text preceding `text` in the same word
|
|
427
|
+
/// segmentation context. `buffer` is scratch space.
|
|
428
|
+
fn capitalize(
|
|
429
|
+
context: &str,
|
|
430
|
+
text: &str,
|
|
431
|
+
lang: &LanguageIdentifier,
|
|
432
|
+
buffer: &mut String,
|
|
433
|
+
output: &mut OutputSink<'_>,
|
|
434
|
+
) {
|
|
435
|
+
let context_len = context.len();
|
|
436
|
+
let combined = if context.is_empty() {
|
|
437
|
+
text
|
|
438
|
+
} else {
|
|
439
|
+
buffer.clear();
|
|
440
|
+
buffer.push_str(context);
|
|
441
|
+
buffer.push_str(text);
|
|
442
|
+
buffer
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
let mut segment_start = 0;
|
|
446
|
+
for segment_end in WORD_SEGMENTER.segment_str(combined).skip(1) {
|
|
447
|
+
if segment_end > context_len {
|
|
448
|
+
let part_start = segment_start.max(context_len);
|
|
449
|
+
let part = &combined[part_start..segment_end];
|
|
450
|
+
|
|
451
|
+
// A word that started in a previous text node has already had its first
|
|
452
|
+
// letter unit (if any) transformed (or not) as part of that text node.
|
|
453
|
+
let first_letter_already_seen = combined[segment_start..part_start]
|
|
454
|
+
.chars()
|
|
455
|
+
.any(is_typographic_letter_unit);
|
|
456
|
+
|
|
457
|
+
match part.find(is_typographic_letter_unit) {
|
|
458
|
+
Some(letter_start) if !first_letter_already_seen => {
|
|
459
|
+
output.push_str(&part[..letter_start]);
|
|
460
|
+
titlecase_segment(&part[letter_start..], lang, output);
|
|
461
|
+
}
|
|
462
|
+
_ => output.push_str(part),
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
segment_start = segment_end;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
#[cfg(feature = "text-transform-icu")]
|
|
470
|
+
fn uppercase(text: &str, lang: &LanguageIdentifier, output: &mut OutputSink<'_>) {
|
|
471
|
+
output.write(CASE_MAPPER.uppercase(text, lang));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
#[cfg(feature = "text-transform-icu")]
|
|
475
|
+
fn lowercase(text: &str, lang: &LanguageIdentifier, output: &mut OutputSink<'_>) {
|
|
476
|
+
output.write(CASE_MAPPER.lowercase(text, lang));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/// Titlecases the first character of `text`, which must start with a typographic letter unit,
|
|
480
|
+
/// leaving the rest unchanged.
|
|
481
|
+
#[cfg(feature = "text-transform-icu")]
|
|
482
|
+
fn titlecase_segment(text: &str, lang: &LanguageIdentifier, output: &mut OutputSink<'_>) {
|
|
483
|
+
let mut options = TitlecaseOptions::default();
|
|
484
|
+
options.leading_adjustment = Some(LeadingAdjustment::None);
|
|
485
|
+
options.trailing_case = Some(TrailingCase::Unchanged);
|
|
486
|
+
output.write(TITLECASE_MAPPER.titlecase_segment(text, lang, options));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
#[cfg(not(feature = "text-transform-icu"))]
|
|
490
|
+
fn uppercase(text: &str, _lang: &LanguageIdentifier, output: &mut OutputSink<'_>) {
|
|
491
|
+
for c in text.chars() {
|
|
492
|
+
push_chars(output, c.to_uppercase());
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
#[cfg(not(feature = "text-transform-icu"))]
|
|
497
|
+
fn lowercase(text: &str, _lang: &LanguageIdentifier, output: &mut OutputSink<'_>) {
|
|
498
|
+
// Only `str::to_lowercase` implements the context-sensitive final sigma rule.
|
|
499
|
+
if text.contains('Σ') {
|
|
500
|
+
output.push_str(&text.to_lowercase());
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
for c in text.chars() {
|
|
504
|
+
push_chars(output, c.to_lowercase());
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/// Approximates titlecasing with uppercasing, as the standard library has no titlecase mapping.
|
|
509
|
+
#[cfg(not(feature = "text-transform-icu"))]
|
|
510
|
+
fn titlecase_segment(text: &str, _lang: &LanguageIdentifier, output: &mut OutputSink<'_>) {
|
|
511
|
+
let mut chars = text.chars();
|
|
512
|
+
if let Some(first) = chars.next() {
|
|
513
|
+
push_chars(output, first.to_uppercase());
|
|
514
|
+
}
|
|
515
|
+
output.push_str(chars.as_str());
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
#[cfg(not(feature = "text-transform-icu"))]
|
|
519
|
+
fn push_chars(output: &mut OutputSink<'_>, chars: impl Iterator<Item = char>) {
|
|
520
|
+
for c in chars {
|
|
521
|
+
output.push_str(c.encode_utf8(&mut [0; 4]));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/// <https://drafts.csswg.org/css-text/#typographic-letter-unit>
|
|
526
|
+
fn is_typographic_letter_unit(c: char) -> bool {
|
|
527
|
+
let category = GENERAL_CATEGORY.get(c);
|
|
528
|
+
GeneralCategoryGroup::Letter.contains(category)
|
|
529
|
+
|| GeneralCategoryGroup::Number.contains(category)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
fn ceil_char_boundary(s: &str, mut index: usize) -> usize {
|
|
533
|
+
while !s.is_char_boundary(index) {
|
|
534
|
+
index += 1;
|
|
535
|
+
}
|
|
536
|
+
index
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
#[cfg(test)]
|
|
540
|
+
mod tests {
|
|
541
|
+
use super::*;
|
|
542
|
+
use parley::{FontContext, LayoutContext, TextStyle};
|
|
543
|
+
|
|
544
|
+
fn transform(kind: TextTransform, lang: &str, texts: &[&str]) -> Vec<String> {
|
|
545
|
+
let transform = CaseTransform {
|
|
546
|
+
text_transform: kind,
|
|
547
|
+
lang: casing_language(LanguageIdentifier::try_from_str(lang).unwrap()),
|
|
548
|
+
..CaseTransform::NONE
|
|
549
|
+
};
|
|
550
|
+
with_builder(|builder, transformer| {
|
|
551
|
+
texts
|
|
552
|
+
.iter()
|
|
553
|
+
.map(|text| push(builder, transformer, text, &transform))
|
|
554
|
+
.collect()
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
fn with_builder<R>(f: impl FnOnce(&mut TreeBuilder<'_, ()>, &mut TextTransformer) -> R) -> R {
|
|
559
|
+
let mut font_ctx = FontContext::new();
|
|
560
|
+
let mut layout_ctx = LayoutContext::new();
|
|
561
|
+
let mut builder = layout_ctx.tree_builder(&mut font_ctx, 1.0, true, &TextStyle::default());
|
|
562
|
+
f(&mut builder, &mut TextTransformer::default())
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
fn push(
|
|
566
|
+
builder: &mut TreeBuilder<'_, ()>,
|
|
567
|
+
transformer: &mut TextTransformer,
|
|
568
|
+
text: &str,
|
|
569
|
+
transform: &CaseTransform,
|
|
570
|
+
) -> String {
|
|
571
|
+
let output = transformer.transform(text, transform, builder).to_owned();
|
|
572
|
+
builder.push_text(&output);
|
|
573
|
+
output
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
fn capitalize(texts: &[&str]) -> Vec<String> {
|
|
577
|
+
transform(TextTransform::CAPITALIZE, "und", texts)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
#[test]
|
|
581
|
+
fn capitalize_words() {
|
|
582
|
+
assert_eq!(capitalize(&["hello world"]), ["Hello World"]);
|
|
583
|
+
assert_eq!(capitalize(&["foo-bar"]), ["Foo-Bar"]);
|
|
584
|
+
assert_eq!(capitalize(&["john's apple"]), ["John's Apple"]);
|
|
585
|
+
assert_eq!(capitalize(&["foo_bar"]), ["Foo_bar"]);
|
|
586
|
+
assert_eq!(capitalize(&["cancel·lar"]), ["Cancel·lar"]);
|
|
587
|
+
assert_eq!(capitalize(&["un·e député·e"]), ["Un·e Député·e"]);
|
|
588
|
+
assert_eq!(capitalize(&["3rd place"]), ["3rd Place"]);
|
|
589
|
+
assert_eq!(capitalize(&["aBC"]), ["ABC"]);
|
|
590
|
+
// Circled letters are symbols, not letters
|
|
591
|
+
assert_eq!(capitalize(&["ⓐⓐⓐ ⓑⓑⓑ"]), ["ⓐⓐⓐ ⓑⓑⓑ"]);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
#[test]
|
|
595
|
+
fn capitalize_skips_leading_punctuation() {
|
|
596
|
+
assert_eq!(
|
|
597
|
+
capitalize(&["({[-–\"«'.<?!transform"]),
|
|
598
|
+
["({[-–\"«'.<?!Transform"]
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
#[test]
|
|
603
|
+
#[cfg(feature = "text-transform-icu")]
|
|
604
|
+
fn capitalize_uses_titlecase() {
|
|
605
|
+
assert_eq!(capitalize(&["dždž"]), ["Dždž"]);
|
|
606
|
+
assert_eq!(capitalize(&["ßa"]), ["Ssa"]);
|
|
607
|
+
assert_eq!(capitalize(&["ᾳᾳ"]), ["ᾼᾳ"]);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
#[test]
|
|
611
|
+
fn capitalize_across_text_nodes() {
|
|
612
|
+
assert_eq!(
|
|
613
|
+
capitalize(&["hel", "lo ", "world"]),
|
|
614
|
+
["Hel", "lo ", "World"]
|
|
615
|
+
);
|
|
616
|
+
assert_eq!(capitalize(&["don", "'t"]), ["Don", "'t"]);
|
|
617
|
+
assert_eq!(capitalize(&["(", "abc"]), ["(", "Abc"]);
|
|
618
|
+
assert_eq!(
|
|
619
|
+
capitalize(&["hello ", " ", "world"]),
|
|
620
|
+
["Hello ", " ", "World"]
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
#[test]
|
|
625
|
+
fn capitalize_mid_word_text_node() {
|
|
626
|
+
let capitalize = CaseTransform {
|
|
627
|
+
text_transform: TextTransform::CAPITALIZE,
|
|
628
|
+
lang: LanguageIdentifier::UNKNOWN,
|
|
629
|
+
..CaseTransform::NONE
|
|
630
|
+
};
|
|
631
|
+
with_builder(|builder, transformer| {
|
|
632
|
+
assert_eq!(push(builder, transformer, "a", &CaseTransform::NONE), "a");
|
|
633
|
+
assert_eq!(push(builder, transformer, "b", &capitalize), "b");
|
|
634
|
+
assert_eq!(push(builder, transformer, "c", &CaseTransform::NONE), "c");
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
#[test]
|
|
639
|
+
fn capitalize_after_word_break() {
|
|
640
|
+
let capitalize = CaseTransform {
|
|
641
|
+
text_transform: TextTransform::CAPITALIZE,
|
|
642
|
+
lang: LanguageIdentifier::UNKNOWN,
|
|
643
|
+
..CaseTransform::NONE
|
|
644
|
+
};
|
|
645
|
+
with_builder(|builder, transformer| {
|
|
646
|
+
assert_eq!(push(builder, transformer, "abc", &capitalize), "Abc");
|
|
647
|
+
transformer.word_break(builder);
|
|
648
|
+
assert_eq!(push(builder, transformer, "def", &capitalize), "Def");
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
#[test]
|
|
653
|
+
fn capitalize_after_collapsed_whitespace() {
|
|
654
|
+
assert_eq!(capitalize(&["hello ", "world"]), ["Hello ", "World"]);
|
|
655
|
+
assert_eq!(capitalize(&["hello\n", "world"]), ["Hello\n", "World"]);
|
|
656
|
+
assert_eq!(capitalize(&["hello ", "(world"]), ["Hello ", "(World"]);
|
|
657
|
+
assert_eq!(
|
|
658
|
+
capitalize(&["hello ", "\u{301}world"]),
|
|
659
|
+
["Hello ", "\u{301}World"]
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
#[test]
|
|
664
|
+
fn capitalize_long_words() {
|
|
665
|
+
let long_word = "a".repeat(100);
|
|
666
|
+
let output = capitalize(&[&long_word, "bc de"]);
|
|
667
|
+
assert_eq!(output[1], "bc De");
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
#[test]
|
|
671
|
+
#[cfg(feature = "text-transform-icu")]
|
|
672
|
+
fn capitalize_language_sensitive() {
|
|
673
|
+
assert_eq!(
|
|
674
|
+
transform(TextTransform::CAPITALIZE, "nl", &["ijsland"]),
|
|
675
|
+
["IJsland"]
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
#[test]
|
|
680
|
+
fn unchanged_text_is_borrowed() {
|
|
681
|
+
let cases = [
|
|
682
|
+
(TextTransform::UPPERCASE, "ABC 123", true),
|
|
683
|
+
(TextTransform::UPPERCASE, "ABc", false),
|
|
684
|
+
(TextTransform::LOWERCASE, "abc 123", true),
|
|
685
|
+
(TextTransform::LOWERCASE, "abC", false),
|
|
686
|
+
(TextTransform::CAPITALIZE, "Hello World", true),
|
|
687
|
+
(TextTransform::CAPITALIZE, "Hello world", false),
|
|
688
|
+
];
|
|
689
|
+
with_builder(|builder, transformer| {
|
|
690
|
+
for (kind, text, borrowed) in cases {
|
|
691
|
+
let transform = CaseTransform {
|
|
692
|
+
text_transform: kind,
|
|
693
|
+
lang: LanguageIdentifier::UNKNOWN,
|
|
694
|
+
..CaseTransform::NONE
|
|
695
|
+
};
|
|
696
|
+
transformer.word_break(builder);
|
|
697
|
+
let output = transformer.transform(text, &transform, builder);
|
|
698
|
+
assert_eq!(output.as_ptr() == text.as_ptr(), borrowed, "{text:?}");
|
|
699
|
+
builder.push_text(" ");
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
#[test]
|
|
705
|
+
#[cfg(feature = "text-transform-icu")]
|
|
706
|
+
fn ascii_fast_path_matches_icu() {
|
|
707
|
+
let ascii: String = (0..128u8).map(char::from).collect();
|
|
708
|
+
for lang in ["und", "en", "lt", "nl", "el", "tr", "az"] {
|
|
709
|
+
let locale = LanguageIdentifier::try_from_str(lang).unwrap();
|
|
710
|
+
assert_eq!(
|
|
711
|
+
transform(TextTransform::UPPERCASE, lang, &[&ascii]),
|
|
712
|
+
[CASE_MAPPER.uppercase_to_string(&ascii, &locale)],
|
|
713
|
+
"{lang}"
|
|
714
|
+
);
|
|
715
|
+
assert_eq!(
|
|
716
|
+
transform(TextTransform::LOWERCASE, lang, &[&ascii]),
|
|
717
|
+
[CASE_MAPPER.lowercase_to_string(&ascii, &locale)],
|
|
718
|
+
"{lang}"
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
#[test]
|
|
724
|
+
fn uppercase_and_lowercase() {
|
|
725
|
+
assert_eq!(
|
|
726
|
+
transform(TextTransform::UPPERCASE, "und", &["straße"]),
|
|
727
|
+
["STRASSE"]
|
|
728
|
+
);
|
|
729
|
+
assert_eq!(
|
|
730
|
+
transform(TextTransform::LOWERCASE, "und", &["ABC"]),
|
|
731
|
+
["abc"]
|
|
732
|
+
);
|
|
733
|
+
assert_eq!(
|
|
734
|
+
transform(TextTransform::LOWERCASE, "und", &["ΟΔΟΣ Σ"]),
|
|
735
|
+
["οδος σ"]
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
#[test]
|
|
740
|
+
fn math_auto_italic_mappings() {
|
|
741
|
+
// MathML Core, Appendix C.1.
|
|
742
|
+
let mappings = [
|
|
743
|
+
("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "𝐴𝐵𝐶𝐷𝐸𝐹𝐺𝐻𝐼𝐽𝐾𝐿𝑀𝑁𝑂𝑃𝑄𝑅𝑆𝑇𝑈𝑉𝑊𝑋𝑌𝑍"),
|
|
744
|
+
("abcdefghijklmnopqrstuvwxyz", "𝑎𝑏𝑐𝑑𝑒𝑓𝑔ℎ𝑖𝑗𝑘𝑙𝑚𝑛𝑜𝑝𝑞𝑟𝑠𝑡𝑢𝑣𝑤𝑥𝑦𝑧"),
|
|
745
|
+
("ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡϴΣΤΥΦΧΨΩ", "𝛢𝛣𝛤𝛥𝛦𝛧𝛨𝛩𝛪𝛫𝛬𝛭𝛮𝛯𝛰𝛱𝛲𝛳𝛴𝛵𝛶𝛷𝛸𝛹𝛺"),
|
|
746
|
+
("αβγδεζηθικλμνξοπρςστυφχψω", "𝛼𝛽𝛾𝛿𝜀𝜁𝜂𝜃𝜄𝜅𝜆𝜇𝜈𝜉𝜊𝜋𝜌𝜍𝜎𝜏𝜐𝜑𝜒𝜓𝜔"),
|
|
747
|
+
("ıȷ∇∂ϵϑϰϕϱϖ", "𝚤𝚥𝛻𝜕𝜖𝜗𝜘𝜙𝜚𝜛"),
|
|
748
|
+
];
|
|
749
|
+
for (original, italic) in mappings {
|
|
750
|
+
assert_eq!(original.chars().count(), italic.chars().count());
|
|
751
|
+
for (input, expected) in original.chars().zip(italic.chars()) {
|
|
752
|
+
assert_eq!(
|
|
753
|
+
transform(
|
|
754
|
+
TextTransform::MATH_AUTO,
|
|
755
|
+
"und",
|
|
756
|
+
&[input.encode_utf8(&mut [0; 4])]
|
|
757
|
+
),
|
|
758
|
+
[expected.to_string()],
|
|
759
|
+
"U+{:04X}",
|
|
760
|
+
input as u32,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
#[test]
|
|
767
|
+
fn math_auto_operates_on_each_text_node() {
|
|
768
|
+
assert_eq!(
|
|
769
|
+
transform(
|
|
770
|
+
TextTransform::MATH_AUTO,
|
|
771
|
+
"und",
|
|
772
|
+
&["a", "b", "ab", "∂", "∂∇"]
|
|
773
|
+
),
|
|
774
|
+
["𝑎", "𝑏", "ab", "𝜕", "∂∇"],
|
|
775
|
+
);
|
|
776
|
+
assert_eq!(transform(TextTransform::MATH_AUTO, "tr", &["i"]), ["𝑖"]);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
#[test]
|
|
780
|
+
fn math_auto_unchanged_text_is_borrowed() {
|
|
781
|
+
let transform = CaseTransform {
|
|
782
|
+
text_transform: TextTransform::MATH_AUTO,
|
|
783
|
+
lang: LanguageIdentifier::UNKNOWN,
|
|
784
|
+
..CaseTransform::NONE
|
|
785
|
+
};
|
|
786
|
+
with_builder(|builder, transformer| {
|
|
787
|
+
for text in [
|
|
788
|
+
"", "ab", "a ", " a", "a\u{301}", "∂∇", "1", "+", "é", "\u{3A2}", "ℎ", "𝑎", "😀",
|
|
789
|
+
] {
|
|
790
|
+
let output = transformer.transform(text, &transform, builder);
|
|
791
|
+
assert_eq!(output, text);
|
|
792
|
+
assert_eq!(output.as_ptr(), text.as_ptr());
|
|
793
|
+
}
|
|
794
|
+
assert_eq!(transformer.output.capacity(), 0);
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
fn transform_with(
|
|
799
|
+
text_transform: TextTransform,
|
|
800
|
+
white_space_collapse: WhiteSpaceCollapse,
|
|
801
|
+
texts: &[&str],
|
|
802
|
+
) -> Vec<String> {
|
|
803
|
+
let transform = CaseTransform {
|
|
804
|
+
text_transform,
|
|
805
|
+
white_space_collapse,
|
|
806
|
+
..CaseTransform::NONE
|
|
807
|
+
};
|
|
808
|
+
with_builder(|builder, transformer| {
|
|
809
|
+
texts
|
|
810
|
+
.iter()
|
|
811
|
+
.map(|text| push(builder, transformer, text, &transform))
|
|
812
|
+
.collect()
|
|
813
|
+
})
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
fn full_width_with(white_space_collapse: WhiteSpaceCollapse, text: &str) -> String {
|
|
817
|
+
transform_with(TextTransform::FULL_WIDTH, white_space_collapse, &[text]).remove(0)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
#[test]
|
|
821
|
+
fn full_width_mappings() {
|
|
822
|
+
let ascii: String = ('!'..='~').collect();
|
|
823
|
+
let wide: String = ('\u{FF01}'..='\u{FF5E}').collect();
|
|
824
|
+
assert_eq!(full_width_with(WhiteSpaceCollapse::Collapse, &ascii), wide);
|
|
825
|
+
// <wide> decompositions, reversed.
|
|
826
|
+
assert_eq!(
|
|
827
|
+
full_width_with(WhiteSpaceCollapse::Collapse, "¢£¬¯¦¥₩⦅⦆"),
|
|
828
|
+
"¢£¬ ̄¦¥₩⦅⦆"
|
|
829
|
+
);
|
|
830
|
+
// <narrow> decompositions.
|
|
831
|
+
assert_eq!(
|
|
832
|
+
full_width_with(WhiteSpaceCollapse::Collapse, "。「」、・ヲァッーアン゙゚"),
|
|
833
|
+
"。「」、・ヲァッーアン\u{3099}\u{309A}"
|
|
834
|
+
);
|
|
835
|
+
assert_eq!(
|
|
836
|
+
full_width_with(WhiteSpaceCollapse::Collapse, "\u{FFA0}ᄀ하ᅦᅧᅬᅭᅲᅳᅵ"),
|
|
837
|
+
"\u{3164}ㄱㅎㅏㅔㅕㅚㅛㅠㅡㅣ"
|
|
838
|
+
);
|
|
839
|
+
assert_eq!(
|
|
840
|
+
full_width_with(WhiteSpaceCollapse::Collapse, "│←↑→↓■○"),
|
|
841
|
+
"│←↑→↓■○"
|
|
842
|
+
);
|
|
843
|
+
// Unassigned code points between the halfwidth Hangul blocks are left as is.
|
|
844
|
+
assert_eq!(
|
|
845
|
+
full_width_with(WhiteSpaceCollapse::Collapse, "\u{FFBF}\u{FFC8}\u{FFDD}"),
|
|
846
|
+
"\u{FFBF}\u{FFC8}\u{FFDD}"
|
|
847
|
+
);
|
|
848
|
+
assert_eq!(
|
|
849
|
+
full_width_with(WhiteSpaceCollapse::Collapse, "é\u{A0}あA"),
|
|
850
|
+
"é\u{A0}あA"
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
#[test]
|
|
855
|
+
fn full_width_preserved_spaces() {
|
|
856
|
+
for collapse in [
|
|
857
|
+
WhiteSpaceCollapse::Preserve,
|
|
858
|
+
WhiteSpaceCollapse::BreakSpaces,
|
|
859
|
+
] {
|
|
860
|
+
assert_eq!(
|
|
861
|
+
full_width_with(collapse, " a b\t\n"),
|
|
862
|
+
"\u{3000}a\u{3000}\u{3000}b\t\n"
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
#[test]
|
|
868
|
+
fn full_width_leaves_collapsible_whitespace() {
|
|
869
|
+
use WhiteSpaceCollapse::{Collapse, PreserveBreaks};
|
|
870
|
+
assert_eq!(full_width_with(Collapse, " a \n\t b "), " a \n\t b ");
|
|
871
|
+
assert_eq!(full_width_with(PreserveBreaks, "a \t\n b"), "a \t\n b");
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
#[test]
|
|
875
|
+
fn full_size_kana_mappings() {
|
|
876
|
+
let small = "ぁぃぅぇぉゕゖ𛄲っゃゅょゎ𛅐𛅑𛅒ァィゥェォヵㇰヶ𛅕ㇱㇲッㇳㇴㇵㇶㇷㇸㇹㇺャュョㇻㇼㇽㇾㇿヮ𛅤𛅥𛅦𛅧ァィゥェォャュョッ";
|
|
877
|
+
let full = "あいうえおかけこつやゆよわゐゑをアイウエオカクケコシスツトヌハヒフヘホムヤユヨラリルレロワヰヱヲンアイウエオヤユヨツ";
|
|
878
|
+
assert_eq!(small.chars().count(), full.chars().count());
|
|
879
|
+
let output = transform_with(
|
|
880
|
+
TextTransform::FULL_SIZE_KANA,
|
|
881
|
+
WhiteSpaceCollapse::Collapse,
|
|
882
|
+
&[small],
|
|
883
|
+
);
|
|
884
|
+
assert_eq!(output, [full]);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
#[test]
|
|
888
|
+
fn combined_transforms() {
|
|
889
|
+
let collapse = WhiteSpaceCollapse::Collapse;
|
|
890
|
+
let transform =
|
|
891
|
+
|text_transform, text| transform_with(text_transform, collapse, &[text]).remove(0);
|
|
892
|
+
assert_eq!(
|
|
893
|
+
transform(
|
|
894
|
+
TextTransform::UPPERCASE | TextTransform::FULL_WIDTH,
|
|
895
|
+
"HELLO Transformed world"
|
|
896
|
+
),
|
|
897
|
+
"HELLO TRANSFORMED WORLD"
|
|
898
|
+
);
|
|
899
|
+
assert_eq!(
|
|
900
|
+
transform(
|
|
901
|
+
TextTransform::CAPITALIZE | TextTransform::FULL_WIDTH,
|
|
902
|
+
"HELLO Transformed world"
|
|
903
|
+
),
|
|
904
|
+
"HELLO Transformed World"
|
|
905
|
+
);
|
|
906
|
+
assert_eq!(
|
|
907
|
+
transform(
|
|
908
|
+
TextTransform::UPPERCASE | TextTransform::FULL_WIDTH,
|
|
909
|
+
"straße"
|
|
910
|
+
),
|
|
911
|
+
"STRASSE"
|
|
912
|
+
);
|
|
913
|
+
assert_eq!(
|
|
914
|
+
transform(
|
|
915
|
+
TextTransform::UPPERCASE | TextTransform::FULL_SIZE_KANA,
|
|
916
|
+
"Katakana: ァィゥ"
|
|
917
|
+
),
|
|
918
|
+
"KATAKANA: アイウ"
|
|
919
|
+
);
|
|
920
|
+
assert_eq!(
|
|
921
|
+
transform(
|
|
922
|
+
TextTransform::LOWERCASE
|
|
923
|
+
| TextTransform::FULL_WIDTH
|
|
924
|
+
| TextTransform::FULL_SIZE_KANA,
|
|
925
|
+
"Hiragana: ぁぃ"
|
|
926
|
+
),
|
|
927
|
+
"hiragana: あい"
|
|
928
|
+
);
|
|
929
|
+
// full-width is applied before full-size-kana.
|
|
930
|
+
assert_eq!(
|
|
931
|
+
transform(
|
|
932
|
+
TextTransform::FULL_WIDTH | TextTransform::FULL_SIZE_KANA,
|
|
933
|
+
"ァッ"
|
|
934
|
+
),
|
|
935
|
+
"アツ"
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
#[test]
|
|
940
|
+
fn width_transforms_unchanged_text_is_borrowed() {
|
|
941
|
+
let cases = [
|
|
942
|
+
(TextTransform::FULL_SIZE_KANA, "abc あア ア"),
|
|
943
|
+
(TextTransform::FULL_WIDTH, "AB\u{3000}あ"),
|
|
944
|
+
(TextTransform::FULL_WIDTH, " AB "),
|
|
945
|
+
(
|
|
946
|
+
TextTransform::UPPERCASE | TextTransform::FULL_SIZE_KANA,
|
|
947
|
+
"ABC",
|
|
948
|
+
),
|
|
949
|
+
(TextTransform::UPPERCASE | TextTransform::FULL_WIDTH, "AB"),
|
|
950
|
+
];
|
|
951
|
+
with_builder(|builder, transformer| {
|
|
952
|
+
for (text_transform, text) in cases {
|
|
953
|
+
let transform = CaseTransform {
|
|
954
|
+
text_transform,
|
|
955
|
+
..CaseTransform::NONE
|
|
956
|
+
};
|
|
957
|
+
let output = transformer.transform(text, &transform, builder);
|
|
958
|
+
assert_eq!(output, text);
|
|
959
|
+
assert_eq!(output.as_ptr(), text.as_ptr(), "{text:?}");
|
|
960
|
+
}
|
|
961
|
+
assert_eq!(transformer.output.capacity(), 0);
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
#[test]
|
|
966
|
+
#[cfg(feature = "text-transform-icu")]
|
|
967
|
+
fn uppercase_and_lowercase_language_sensitive() {
|
|
968
|
+
assert_eq!(transform(TextTransform::UPPERCASE, "tr", &["i"]), ["İ"]);
|
|
969
|
+
assert_eq!(transform(TextTransform::LOWERCASE, "tr", &["I"]), ["ı"]);
|
|
970
|
+
assert_eq!(
|
|
971
|
+
transform(TextTransform::LOWERCASE, "tr-Latn", &["I"]),
|
|
972
|
+
["ı"]
|
|
973
|
+
);
|
|
974
|
+
assert_eq!(
|
|
975
|
+
transform(TextTransform::LOWERCASE, "tr-Cyrl", &["I"]),
|
|
976
|
+
["i"]
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
}
|