use std::collections::BTreeMap;
use dxpdf::model::{Block, Inline, RunElement};
use dxpdf::render::layout::draw_command::{DrawCommand, LayoutedPage};
const THAI: &str = "test-files/line-break-thai.docx";
const CJK: &str = "test-files/line-break-cjk.docx";
const MAY_NOT_OPEN_A_LINE: &str = "。、」』)〉?!ぁぃぅぇぉっゃゅょ・:;";
const MAY_NOT_CLOSE_A_LINE: &str = "「『(〈";
fn fixture(path: &str) -> (Vec<String>, Vec<LayoutedPage>) {
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("{path}: {e}"));
let doc = dxpdf::docx::parse(&bytes).unwrap_or_else(|e| panic!("{path} parses: {e}"));
let paragraphs = doc
.body
.iter()
.filter_map(|block| match block {
Block::Paragraph(p) => Some(
p.content
.iter()
.filter_map(|inline| match inline {
Inline::TextRun(run) => {
Some(run.content.iter().filter_map(|el| match el {
RunElement::Text(t) => Some(t.as_str()),
_ => None,
}))
}
_ => None,
})
.flatten()
.collect::<String>(),
),
_ => None,
})
.filter(|text: &String| !text.is_empty())
.collect();
(paragraphs, dxpdf::render::resolve_and_layout(doc).1)
}
fn lines(pages: &[LayoutedPage]) -> Vec<String> {
let mut out = Vec::new();
for page in pages {
let mut by_baseline: BTreeMap<i64, Vec<(i64, &str)>> = BTreeMap::new();
for command in &page.commands {
if let DrawCommand::Text { position, text, .. } = command {
by_baseline
.entry(position.y.raw().round() as i64)
.or_default()
.push((position.x.raw().round() as i64, text));
}
}
for (_, mut run) in by_baseline {
run.sort_by_key(|(x, _)| *x);
let line: String = run.into_iter().map(|(_, text)| text).collect();
if !line.trim().is_empty() {
out.push(line);
}
}
}
out
}
fn by_paragraph(lines: &[String], paragraphs: &[String]) -> Vec<Vec<String>> {
let mut out = Vec::new();
let mut next = 0;
for paragraph in paragraphs {
let mut taken: Vec<String> = Vec::new();
while taken.concat().chars().count() < paragraph.chars().count() {
let line = lines.get(next).unwrap_or_else(|| {
panic!(
"ran out of laid-out lines while rebuilding paragraph {:?}; \
got {:?} so far",
paragraph, taken,
)
});
taken.push(line.clone());
next += 1;
}
assert_eq!(
taken.concat(),
*paragraph,
"the lines of a paragraph must be exactly its text",
);
out.push(taken);
}
assert_eq!(
next,
lines.len(),
"every laid-out line belongs to a paragraph"
);
out
}
fn break_points(paragraph_lines: &[String]) -> Vec<usize> {
let mut offsets = Vec::new();
let mut at = 0;
for line in ¶graph_lines[..paragraph_lines.len() - 1] {
at += line.len();
offsets.push(at);
}
offsets
}
#[test]
fn every_thai_break_is_a_uax14_break_opportunity() {
let (paragraphs, pages) = fixture(THAI);
let grouped = by_paragraph(&lines(&pages), ¶graphs);
let mut checked = 0;
for (paragraph, paragraph_lines) in paragraphs.iter().zip(&grouped) {
let allowed = dxpdf::i18n::segment::break_offsets(paragraph);
for offset in break_points(paragraph_lines) {
assert!(
allowed.contains(&offset),
"broke at byte {offset} — {:?} | {:?} — which UAX #14 does not \
allow",
paragraph[..offset]
.chars()
.rev()
.take(8)
.collect::<String>(),
paragraph[offset..].chars().take(8).collect::<String>(),
);
checked += 1;
}
}
assert!(
checked > 20,
"expected the fixture to force many breaks, checked only {checked}",
);
}
#[test]
fn japanese_never_strands_punctuation_on_a_line_edge() {
let (_, pages) = fixture(CJK);
let lines = lines(&pages);
assert!(lines.len() > 20, "fixture must produce many lines");
for line in &lines {
let first = line.chars().next().expect("no empty lines");
assert!(
!MAY_NOT_OPEN_A_LINE.contains(first),
"LB13: line opens with {first:?} — {line:?}",
);
let last = line.chars().next_back().expect("no empty lines");
assert!(
!MAY_NOT_CLOSE_A_LINE.contains(last),
"LB14: line closes with {last:?} — {line:?}",
);
}
}
#[test]
fn run_boundaries_do_not_change_where_a_paragraph_breaks() {
for path in [THAI, CJK] {
let (paragraphs, pages) = fixture(path);
let grouped = by_paragraph(&lines(&pages), ¶graphs);
let single = grouped.first().expect("fixture has paragraphs");
let split = grouped.last().expect("fixture has paragraphs");
assert_eq!(
paragraphs.first(),
paragraphs.last(),
"{path}: first and last paragraph must carry the same text",
);
assert!(single.len() > 1, "{path}: the text must wrap to be a test");
assert_eq!(
break_points(single),
break_points(split),
"{path}: one run and several runs broke the same text differently",
);
}
}
#[test]
fn lines_are_filled_rather_than_broken_at_the_first_opportunity() {
for path in [THAI, CJK] {
let (paragraphs, pages) = fixture(path);
let grouped = by_paragraph(&lines(&pages), ¶graphs);
for (paragraph, paragraph_lines) in paragraphs.iter().zip(&grouped) {
let widest = paragraph_lines
.iter()
.map(|l| l.chars().count())
.max()
.unwrap_or(0);
for line in ¶graph_lines[..paragraph_lines.len() - 1] {
let len = line.chars().count();
assert!(
len * 4 >= widest * 3,
"{path}: a line of {len} chars against a widest of {widest} \
in the same paragraph — lines are not being filled: \
{line:?} (paragraph {paragraph:?})",
);
}
}
}
}