1use text_document::{
7 BlockSnapshot, CellSnapshot, FlowElementSnapshot, FlowSnapshot, FragmentContent, FrameSnapshot,
8 TableSnapshot,
9};
10
11use crate::layout::block::{BlockLayoutParams, FragmentParams, PaintSpan};
12use crate::layout::frame::{FrameLayoutParams, FramePosition};
13use crate::layout::paragraph::Alignment;
14use crate::layout::table::{CellLayoutParams, TableLayoutParams};
15use crate::shaping::shaper::TextDirection;
16
17const DEFAULT_LIST_INDENT: f32 = 24.0;
18const INDENT_PER_LEVEL: f32 = 24.0;
19
20fn iso639_1(code: &str) -> Option<[u8; 2]> {
26 let b = code.trim().as_bytes();
27 if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1].is_ascii_alphabetic() {
28 Some([b[0].to_ascii_lowercase(), b[1].to_ascii_lowercase()])
29 } else {
30 None
31 }
32}
33
34#[derive(Clone, Copy)]
41pub struct BridgeOptions {
42 pub code_block_background: [f32; 4],
47 pub code_block_foreground: Option<[f32; 4]>,
53 pub echo_char: Option<char>,
63 pub hyphenate_justified: bool,
71}
72
73impl Default for BridgeOptions {
74 fn default() -> Self {
75 Self {
76 code_block_background: [0.95, 0.95, 0.95, 1.0],
77 code_block_foreground: None,
78 echo_char: None,
79 hyphenate_justified: false,
80 }
81 }
82}
83
84pub fn convert_flow(flow: &FlowSnapshot) -> FlowElements {
88 convert_flow_with(flow, &BridgeOptions::default())
89}
90
91pub fn convert_flow_with(flow: &FlowSnapshot, opts: &BridgeOptions) -> FlowElements {
94 let mut blocks = Vec::new();
95 let mut tables = Vec::new();
96 let mut frames = Vec::new();
97
98 for (i, element) in flow.elements.iter().enumerate() {
99 match element {
100 FlowElementSnapshot::Block(block) => {
101 blocks.push((i, convert_block_with(block, opts)));
102 }
103 FlowElementSnapshot::Table(table) => {
104 tables.push((i, convert_table_with(table, opts)));
105 }
106 FlowElementSnapshot::Frame(frame) => {
107 frames.push((i, convert_frame_with(frame, opts)));
108 }
109 }
110 }
111
112 FlowElements {
113 blocks,
114 tables,
115 frames,
116 }
117}
118
119pub struct FlowElements {
121 pub blocks: Vec<(usize, BlockLayoutParams)>,
123 pub tables: Vec<(usize, TableLayoutParams)>,
124 pub frames: Vec<(usize, FrameLayoutParams)>,
125}
126
127pub fn convert_block(block: &BlockSnapshot) -> BlockLayoutParams {
128 convert_block_with(block, &BridgeOptions::default())
129}
130
131pub fn convert_block_with(block: &BlockSnapshot, opts: &BridgeOptions) -> BlockLayoutParams {
134 let alignment = block
135 .block_format
136 .alignment
137 .as_ref()
138 .map(convert_alignment)
139 .unwrap_or_default();
140
141 let heading_scale = match block.block_format.heading_level {
142 Some(1) => 2.0,
143 Some(2) => 1.5,
144 Some(3) => 1.25,
145 Some(4) => 1.1,
146 _ => 1.0,
147 };
148
149 let char_to_byte: Vec<usize> = block
167 .text
168 .char_indices()
169 .map(|(b, _)| b)
170 .chain(std::iter::once(block.text.len()))
171 .collect();
172 let fragments: Vec<FragmentParams> = block
173 .fragments
174 .iter()
175 .map(|f| {
176 let char_offset = match f {
177 FragmentContent::Text { offset, .. } => *offset,
178 FragmentContent::Image { offset, .. } => *offset,
179 FragmentContent::FootnoteReference { offset, .. } => *offset,
180 };
181 let byte_offset = char_to_byte
182 .get(char_offset)
183 .copied()
184 .unwrap_or(block.text.len());
185 convert_fragment(f, heading_scale, opts, byte_offset)
186 })
187 .collect();
188
189 let indent_level = block.block_format.indent.unwrap_or(0) as f32;
190
191 let (list_marker, list_indent) = if let Some(ref info) = block.list_info {
192 let list_indent_level = info.indent as f32;
193 (
194 info.marker.clone(),
195 DEFAULT_LIST_INDENT + list_indent_level * INDENT_PER_LEVEL,
196 )
197 } else {
198 (String::new(), indent_level * INDENT_PER_LEVEL)
199 };
200
201 let checkbox = match block.block_format.marker {
202 Some(text_document::MarkerType::Checked) => Some(true),
203 Some(text_document::MarkerType::Unchecked) => Some(false),
204 _ => None,
205 };
206
207 let mut params = BlockLayoutParams {
208 block_id: block.block_id,
209 position: block.position,
210 text: block.text.clone(),
211 fragments,
212 alignment,
213 base_direction: convert_direction(block.block_format.direction.as_ref()),
214 top_margin: block.block_format.top_margin.unwrap_or(0) as f32,
215 bottom_margin: block.block_format.bottom_margin.unwrap_or(0) as f32,
216 left_margin: block.block_format.left_margin.unwrap_or(0) as f32,
217 right_margin: block.block_format.right_margin.unwrap_or(0) as f32,
218 text_indent: block.block_format.text_indent.unwrap_or(0) as f32,
219 list_marker,
220 list_indent,
221 tab_positions: block
222 .block_format
223 .tab_positions
224 .iter()
225 .map(|&t| t as f32)
226 .collect(),
227 line_height_multiplier: block.block_format.line_height,
228 non_breakable_lines: block.block_format.non_breakable_lines.unwrap_or(false)
229 || block.block_format.is_code_block == Some(true),
230 hyphenation: {
237 let enabled = match block.block_format.hyphenate {
238 Some(v) => v,
239 None => opts.hyphenate_justified && alignment == Alignment::Justify,
240 };
241 enabled.then(|| crate::types::Hyphenation {
242 language: block
243 .block_format
244 .language
245 .as_deref()
246 .and_then(iso639_1)
247 .unwrap_or(*b"en"),
248 })
249 },
250 checkbox,
251 background_color: block
252 .block_format
253 .background_color
254 .as_ref()
255 .and_then(|s| parse_css_color(s))
256 .or_else(|| {
257 if block.block_format.is_code_block == Some(true) {
258 Some(opts.code_block_background)
259 } else {
260 None
261 }
262 }),
263 };
264
265 if let Some(echo) = opts.echo_char {
266 mask_block_params(&mut params, echo);
267 }
268
269 params
270}
271
272fn mask_block_params(params: &mut BlockLayoutParams, echo: char) {
281 if params.fragments.is_empty() {
282 params.text = echo.to_string().repeat(params.text.chars().count());
283 return;
284 }
285 let mut masked_block = String::new();
286 let mut byte_cursor = 0usize;
287 for frag in params.fragments.iter_mut() {
288 frag.offset = byte_cursor;
289 if frag.image_name.is_some() {
290 masked_block.push_str(&frag.text);
293 byte_cursor += frag.text.len();
294 continue;
295 }
296 let masked = echo.to_string().repeat(frag.text.chars().count());
297 byte_cursor += masked.len();
298 masked_block.push_str(&masked);
299 frag.text = masked;
300 }
301 params.text = masked_block;
302}
303
304fn convert_fragment(
305 frag: &FragmentContent,
306 heading_scale: f32,
307 opts: &BridgeOptions,
308 byte_offset: usize,
309) -> FragmentParams {
310 match frag {
311 FragmentContent::Text {
312 text,
313 format,
314 length,
315 ..
316 } => {
317 let is_monospace = format
322 .font_family
323 .as_deref()
324 .map(|f| f.eq_ignore_ascii_case("monospace"))
325 .unwrap_or(false);
326 let foreground_color =
327 format
328 .foreground_color
329 .as_ref()
330 .map(convert_color)
331 .or(if is_monospace {
332 opts.code_block_foreground
333 } else {
334 None
335 });
336 FragmentParams {
337 text: text.clone(),
338 offset: byte_offset,
339 length: *length,
340 font_family: format.font_family.clone(),
341 font_weight: format.font_weight,
342 font_bold: format.font_bold,
343 font_italic: format.font_italic,
344 font_point_size: if heading_scale != 1.0 {
345 Some((format.font_point_size.unwrap_or(16) as f32 * heading_scale) as u32)
347 } else {
348 format.font_point_size
349 },
350 underline_style: convert_underline_style(format),
351 overline: format.font_overline.unwrap_or(false),
352 strikeout: format.font_strikeout.unwrap_or(false),
353 is_link: format.is_anchor.unwrap_or(false),
354 letter_spacing: format.letter_spacing.unwrap_or(0) as f32,
355 word_spacing: format.word_spacing.unwrap_or(0) as f32,
356 foreground_color,
357 underline_color: format.underline_color.as_ref().map(convert_color),
358 background_color: format.background_color.as_ref().map(convert_color),
359 anchor_href: format.anchor_href.clone(),
360 tooltip: format.tooltip.clone(),
361 vertical_alignment: convert_vertical_alignment(format),
362 image_name: None,
363 image_width: 0.0,
364 image_height: 0.0,
365 footnote_marker: None,
366 features: Vec::new(),
367 }
368 }
369 FragmentContent::FootnoteReference { marker, format, .. } => FragmentParams {
374 text: "\u{FFFC}".to_string(),
375 offset: byte_offset,
376 length: 1,
377 font_family: format.font_family.clone(),
378 font_weight: format.font_weight,
379 font_bold: format.font_bold,
380 font_italic: format.font_italic,
381 font_point_size: format.font_point_size,
382 underline_style: convert_underline_style(format),
383 overline: format.font_overline.unwrap_or(false),
384 strikeout: format.font_strikeout.unwrap_or(false),
385 is_link: format.is_anchor.unwrap_or(false),
386 letter_spacing: 0.0,
387 word_spacing: 0.0,
388 foreground_color: format.foreground_color.as_ref().map(convert_color),
389 underline_color: format.underline_color.as_ref().map(convert_color),
390 background_color: format.background_color.as_ref().map(convert_color),
391 anchor_href: format.anchor_href.clone(),
392 tooltip: format.tooltip.clone(),
393 vertical_alignment: convert_vertical_alignment(format),
394 image_name: None,
395 image_width: 0.0,
396 image_height: 0.0,
397 footnote_marker: Some(marker.clone()),
398 features: Vec::new(),
399 },
400 FragmentContent::Image {
401 name,
402 width,
403 height,
404 quality: _,
405 format,
406 ..
407 } => FragmentParams {
408 text: "\u{FFFC}".to_string(),
409 offset: byte_offset,
410 length: 1,
411 font_family: None,
412 font_weight: None,
413 font_bold: None,
414 font_italic: None,
415 font_point_size: None,
416 underline_style: crate::types::UnderlineStyle::None,
417 overline: false,
418 strikeout: false,
419 is_link: format.is_anchor.unwrap_or(false),
420 letter_spacing: 0.0,
421 word_spacing: 0.0,
422 foreground_color: None,
423 underline_color: None,
424 background_color: None,
425 anchor_href: format.anchor_href.clone(),
426 tooltip: format.tooltip.clone(),
427 vertical_alignment: crate::types::VerticalAlignment::Normal,
428 image_name: Some(name.clone()),
429 image_width: *width as f32,
430 image_height: *height as f32,
431 footnote_marker: None,
432 features: Vec::new(),
433 },
434 }
435}
436
437fn convert_vertical_alignment(
438 format: &text_document::TextFormat,
439) -> crate::types::VerticalAlignment {
440 use crate::types::VerticalAlignment;
441 match format.vertical_alignment {
442 Some(text_document::CharVerticalAlignment::SuperScript) => VerticalAlignment::SuperScript,
443 Some(text_document::CharVerticalAlignment::SubScript) => VerticalAlignment::SubScript,
444 _ => VerticalAlignment::Normal,
445 }
446}
447
448fn convert_underline_style(format: &text_document::TextFormat) -> crate::types::UnderlineStyle {
449 use crate::types::UnderlineStyle;
450 match &format.underline_style {
451 Some(s) => convert_underline_style_value(s),
452 None => {
453 if format.font_underline.unwrap_or(false) {
454 UnderlineStyle::Single
455 } else {
456 UnderlineStyle::None
457 }
458 }
459 }
460}
461
462fn convert_underline_style_value(
464 s: &text_document::UnderlineStyle,
465) -> crate::types::UnderlineStyle {
466 use crate::types::UnderlineStyle;
467 match s {
468 text_document::UnderlineStyle::SingleUnderline => UnderlineStyle::Single,
469 text_document::UnderlineStyle::DashUnderline => UnderlineStyle::Dash,
470 text_document::UnderlineStyle::DotLine => UnderlineStyle::Dot,
471 text_document::UnderlineStyle::DashDotLine => UnderlineStyle::DashDot,
472 text_document::UnderlineStyle::DashDotDotLine => UnderlineStyle::DashDotDot,
473 text_document::UnderlineStyle::WaveUnderline => UnderlineStyle::Wave,
474 text_document::UnderlineStyle::SpellCheckUnderline => UnderlineStyle::SpellCheck,
475 text_document::UnderlineStyle::NoUnderline => UnderlineStyle::None,
476 }
477}
478
479pub fn convert_paint_spans(block: &BlockSnapshot) -> Vec<PaintSpan> {
485 block
486 .paint_highlights
487 .iter()
488 .map(|h| {
489 let underline_style = match &h.underline_style {
490 Some(s) => Some(convert_underline_style_value(s)),
491 None => match h.font_underline {
492 Some(true) => Some(crate::types::UnderlineStyle::Single),
493 Some(false) => Some(crate::types::UnderlineStyle::None),
494 None => None,
495 },
496 };
497 PaintSpan {
498 char_start: h.start,
499 char_end: h.start + h.length,
500 foreground_color: h.foreground_color.as_ref().map(convert_color),
501 underline_color: h.underline_color.as_ref().map(convert_color),
502 background_color: h.background_color.as_ref().map(convert_color),
503 underline_style,
504 overline: h.font_overline,
505 strikeout: h.font_strikeout,
506 }
507 })
508 .collect()
509}
510
511pub fn collect_paint_spans(
516 flow: &FlowSnapshot,
517) -> std::collections::HashMap<usize, Vec<PaintSpan>> {
518 let mut out = std::collections::HashMap::new();
519 for el in &flow.elements {
520 collect_paint_spans_element(el, &mut out);
521 }
522 out
523}
524
525fn collect_paint_spans_element(
526 el: &FlowElementSnapshot,
527 out: &mut std::collections::HashMap<usize, Vec<PaintSpan>>,
528) {
529 match el {
530 FlowElementSnapshot::Block(b) => {
531 if !b.paint_highlights.is_empty() {
532 out.insert(b.block_id, convert_paint_spans(b));
533 }
534 }
535 FlowElementSnapshot::Table(t) => {
536 for c in &t.cells {
537 for b in &c.blocks {
538 if !b.paint_highlights.is_empty() {
539 out.insert(b.block_id, convert_paint_spans(b));
540 }
541 }
542 }
543 }
544 FlowElementSnapshot::Frame(f) => {
545 for e in &f.elements {
546 collect_paint_spans_element(e, out);
547 }
548 }
549 }
550}
551
552fn convert_color(c: &text_document::Color) -> [f32; 4] {
553 [
554 c.red as f32 / 255.0,
555 c.green as f32 / 255.0,
556 c.blue as f32 / 255.0,
557 c.alpha as f32 / 255.0,
558 ]
559}
560
561fn parse_css_color(s: &str) -> Option<[f32; 4]> {
566 let s = s.trim();
567
568 match s.to_ascii_lowercase().as_str() {
570 "transparent" => return Some([0.0, 0.0, 0.0, 0.0]),
571 "black" => return Some([0.0, 0.0, 0.0, 1.0]),
572 "white" => return Some([1.0, 1.0, 1.0, 1.0]),
573 "red" => return Some([1.0, 0.0, 0.0, 1.0]),
574 "green" => return Some([0.0, 128.0 / 255.0, 0.0, 1.0]),
575 "blue" => return Some([0.0, 0.0, 1.0, 1.0]),
576 "yellow" => return Some([1.0, 1.0, 0.0, 1.0]),
577 "cyan" | "aqua" => return Some([0.0, 1.0, 1.0, 1.0]),
578 "magenta" | "fuchsia" => return Some([1.0, 0.0, 1.0, 1.0]),
579 "gray" | "grey" => return Some([128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]),
580 _ => {}
581 }
582
583 if let Some(hex) = s.strip_prefix('#') {
585 let hex = hex.trim();
586 return match hex.len() {
587 3 => {
588 let r = u8::from_str_radix(&hex[0..1], 16).ok()?;
590 let g = u8::from_str_radix(&hex[1..2], 16).ok()?;
591 let b = u8::from_str_radix(&hex[2..3], 16).ok()?;
592 Some([
593 (r * 17) as f32 / 255.0,
594 (g * 17) as f32 / 255.0,
595 (b * 17) as f32 / 255.0,
596 1.0,
597 ])
598 }
599 4 => {
600 let r = u8::from_str_radix(&hex[0..1], 16).ok()?;
602 let g = u8::from_str_radix(&hex[1..2], 16).ok()?;
603 let b = u8::from_str_radix(&hex[2..3], 16).ok()?;
604 let a = u8::from_str_radix(&hex[3..4], 16).ok()?;
605 Some([
606 (r * 17) as f32 / 255.0,
607 (g * 17) as f32 / 255.0,
608 (b * 17) as f32 / 255.0,
609 (a * 17) as f32 / 255.0,
610 ])
611 }
612 6 => {
613 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
615 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
616 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
617 Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0])
618 }
619 8 => {
620 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
622 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
623 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
624 let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
625 Some([
626 r as f32 / 255.0,
627 g as f32 / 255.0,
628 b as f32 / 255.0,
629 a as f32 / 255.0,
630 ])
631 }
632 _ => None,
633 };
634 }
635
636 let inner = s
638 .strip_prefix("rgba(")
639 .and_then(|s| s.strip_suffix(')'))
640 .or_else(|| s.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')))?;
641
642 let parts: Vec<&str> = inner.split(',').collect();
643 match parts.len() {
644 3 => {
645 let r: u8 = parts[0].trim().parse().ok()?;
646 let g: u8 = parts[1].trim().parse().ok()?;
647 let b: u8 = parts[2].trim().parse().ok()?;
648 Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0])
649 }
650 4 => {
651 let r: u8 = parts[0].trim().parse().ok()?;
652 let g: u8 = parts[1].trim().parse().ok()?;
653 let b: u8 = parts[2].trim().parse().ok()?;
654 let a: f32 = parts[3].trim().parse().ok()?;
655 Some([r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a])
656 }
657 _ => None,
658 }
659}
660
661fn convert_alignment(a: &text_document::Alignment) -> Alignment {
662 match a {
663 text_document::Alignment::Left => Alignment::Left,
664 text_document::Alignment::Right => Alignment::Right,
665 text_document::Alignment::Center => Alignment::Center,
666 text_document::Alignment::Justify => Alignment::Justify,
667 }
668}
669
670fn convert_direction(d: Option<&text_document::TextDirection>) -> TextDirection {
677 match d {
678 None => TextDirection::Auto,
679 Some(text_document::TextDirection::LeftToRight) => TextDirection::LeftToRight,
680 Some(text_document::TextDirection::RightToLeft) => TextDirection::RightToLeft,
681 }
682}
683
684pub fn convert_table(table: &TableSnapshot) -> TableLayoutParams {
685 convert_table_with(table, &BridgeOptions::default())
686}
687
688pub fn convert_table_with(table: &TableSnapshot, opts: &BridgeOptions) -> TableLayoutParams {
689 let column_widths: Vec<f32> = table.column_widths.iter().map(|&w| w as f32).collect();
690
691 let cells: Vec<CellLayoutParams> = table.cells.iter().map(|c| convert_cell(c, opts)).collect();
692
693 TableLayoutParams {
694 table_id: table.table_id,
695 rows: table.rows,
696 columns: table.columns,
697 column_widths,
698 border_width: table.format.border.unwrap_or(1) as f32,
699 cell_spacing: table.format.cell_spacing.unwrap_or(0) as f32,
700 cell_padding: table.format.cell_padding.unwrap_or(4) as f32,
701 cells,
702 }
703}
704
705fn convert_cell(cell: &CellSnapshot, opts: &BridgeOptions) -> CellLayoutParams {
706 let blocks: Vec<BlockLayoutParams> = cell
707 .blocks
708 .iter()
709 .map(|b| convert_block_with(b, opts))
710 .collect();
711
712 let background_color = cell
713 .format
714 .background_color
715 .as_ref()
716 .and_then(|s| parse_css_color(s));
717
718 CellLayoutParams {
719 row: cell.row,
720 column: cell.column,
721 blocks,
722 background_color,
723 }
724}
725
726pub fn convert_frame(frame: &FrameSnapshot) -> FrameLayoutParams {
727 convert_frame_with(frame, &BridgeOptions::default())
728}
729
730pub fn convert_frame_with(frame: &FrameSnapshot, opts: &BridgeOptions) -> FrameLayoutParams {
731 let mut blocks = Vec::new();
732 let mut tables = Vec::new();
733 let mut frames = Vec::new();
734
735 for (i, element) in frame.elements.iter().enumerate() {
736 match element {
737 FlowElementSnapshot::Block(block) => {
738 blocks.push((i, convert_block_with(block, opts)));
745 }
746 FlowElementSnapshot::Table(table) => {
747 tables.push((i, convert_table_with(table, opts)));
748 }
749 FlowElementSnapshot::Frame(inner_frame) => {
750 frames.push((i, convert_frame_with(inner_frame, opts)));
751 }
752 }
753 }
754
755 let position = match &frame.format.position {
756 Some(text_document::FramePosition::InFlow) | None => FramePosition::Inline,
757 Some(text_document::FramePosition::FloatLeft) => FramePosition::FloatLeft,
758 Some(text_document::FramePosition::FloatRight) => FramePosition::FloatRight,
759 };
760
761 let is_blockquote = frame.format.is_blockquote == Some(true);
762
763 FrameLayoutParams {
764 frame_id: frame.frame_id,
765 position,
766 width: frame.format.width.map(|w| w as f32),
767 height: frame.format.height.map(|h| h as f32),
768 margin_top: frame
769 .format
770 .top_margin
771 .unwrap_or(if is_blockquote { 4 } else { 0 }) as f32,
772 margin_bottom: frame
773 .format
774 .bottom_margin
775 .unwrap_or(if is_blockquote { 4 } else { 0 }) as f32,
776 margin_left: frame
777 .format
778 .left_margin
779 .unwrap_or(if is_blockquote { 16 } else { 0 }) as f32,
780 margin_right: frame.format.right_margin.unwrap_or(0) as f32,
781 padding: frame
782 .format
783 .padding
784 .unwrap_or(if is_blockquote { 8 } else { 0 }) as f32,
785 border_width: frame
786 .format
787 .border
788 .unwrap_or(if is_blockquote { 3 } else { 0 }) as f32,
789 border_style: if is_blockquote {
790 crate::layout::frame::FrameBorderStyle::LeftOnly
791 } else {
792 crate::layout::frame::FrameBorderStyle::Full
793 },
794 blocks,
795 tables,
796 frames,
797 }
798}
799
800#[cfg(test)]
801mod tests {
802 use super::iso639_1;
803
804 #[test]
805 fn iso639_1_parses_two_letter_codes_case_insensitively() {
806 assert_eq!(iso639_1("en"), Some(*b"en"));
807 assert_eq!(iso639_1("FR"), Some(*b"fr"));
808 assert_eq!(iso639_1("De"), Some(*b"de"));
809 assert_eq!(iso639_1("en-US"), Some(*b"en"));
811 assert_eq!(iso639_1(" fr "), Some(*b"fr"));
812 }
813
814 #[test]
815 fn iso639_1_rejects_non_letter_codes() {
816 assert_eq!(iso639_1(""), None);
817 assert_eq!(iso639_1("x"), None);
818 assert_eq!(iso639_1("12"), None);
819 }
820}