use std::collections::HashMap;
use encoding_rs::Encoding;
use crate::chpx::{Chp, ChpxTable};
use crate::clx::Piece;
use crate::fib::{self, Fib};
use crate::list::Numberer;
use crate::model::{
normalize_field_instruction, Align, Block, CharProps, DocMeta, DocModel, DocSetup, FieldRole,
Image, ListInfo, ParaProps, Paragraph, SectionBreakKind, SectionSetup, SourceRegion,
SourceRegionKind, Stats,
};
use crate::papx::PapxTable;
use crate::stsh::StyleSheet;
use crate::table::{self, RowBuild};
use crate::util::u32le;
struct LegacySource<'a> {
units: &'a [u16],
fcs: &'a [u32],
papx: &'a PapxTable,
chpx: &'a ChpxTable,
stylesheet: &'a StyleSheet,
data: &'a [u8],
fonts: &'a [String],
}
struct RegionSpec {
kind: SourceRegionKind,
source_start_cp: usize,
source_len_cp: usize,
source_story_index: Option<usize>,
include_empty: bool,
}
pub(crate) struct BuildInputs<'a> {
pub word: &'a [u8],
pub table: &'a [u8],
pub pieces: &'a [Piece],
pub enc: &'static Encoding,
pub papx: &'a PapxTable,
pub chpx: &'a ChpxTable,
pub stylesheet: &'a StyleSheet,
pub data: &'a [u8],
pub fonts: &'a [String],
pub fib: &'a Fib,
}
pub(crate) fn build_model(inputs: BuildInputs<'_>, numberer: &mut Numberer<'_>) -> DocModel {
let BuildInputs {
word,
table,
pieces,
enc,
papx,
chpx,
stylesheet,
data,
fonts,
fib,
} = inputs;
let (units, fcs) = decode_with_fc(word, pieces, enc);
let section_spans = legacy_section_spans(word, table, fib.ccp_text as usize);
let src = LegacySource {
units: &units,
fcs: &fcs,
papx,
chpx,
stylesheet,
data,
fonts,
};
let (blocks, regions) = build_legacy_region_blocks(&src, numberer, fib, table, §ion_spans);
let mut blocks = blocks;
let stats = compute_stats(&blocks);
let setup = legacy_doc_setup_from_regions(&mut blocks, ®ions);
DocModel {
blocks,
regions,
meta: DocMeta {
codepage: fib.ansi_codepage(),
lid: fib.lid,
stats,
},
custom_properties: Default::default(),
custom_xml_items: Vec::new(),
setup,
}
}
fn legacy_doc_setup_from_regions(blocks: &mut [Block], regions: &[SourceRegion]) -> DocSetup {
let section_count = blocks
.iter()
.filter(|block| matches!(block, Block::SectionBreak(_)))
.count()
.saturating_add(1);
if section_count > 1 {
return legacy_doc_section_setups_from_regions(blocks, regions, section_count);
}
legacy_doc_flat_setup_from_regions(blocks, regions)
}
fn legacy_doc_flat_setup_from_regions(blocks: &[Block], regions: &[SourceRegion]) -> DocSetup {
let mut setup = DocSetup::default();
for region in regions.iter().filter(|region| {
region.kind == SourceRegionKind::HeaderFooter && region.block_start < region.block_end
}) {
let start = region.block_start.min(blocks.len());
let end = region.block_end.min(blocks.len());
if start < end {
let slot = legacy_header_footer_setup_slot(&mut setup, region.source_story_index);
if slot.is_empty() {
*slot = blocks[start..end].to_vec();
}
}
}
setup
}
fn legacy_doc_section_setups_from_regions(
blocks: &mut [Block],
regions: &[SourceRegion],
section_count: usize,
) -> DocSetup {
let mut section_setups = vec![SectionSetup::default(); section_count];
for region in regions.iter().filter(|region| {
region.kind == SourceRegionKind::HeaderFooter && region.block_start < region.block_end
}) {
let Some(section_index) = legacy_header_footer_section_index(region.source_story_index)
else {
continue;
};
let Some(section_setup) = section_setups.get_mut(section_index) else {
continue;
};
let Some(slot) =
legacy_header_footer_section_setup_slot(section_setup, region.source_story_index)
else {
continue;
};
let start = region.block_start.min(blocks.len());
let end = region.block_end.min(blocks.len());
if start < end && slot.is_empty() {
*slot = blocks[start..end].to_vec();
}
}
let mut section_index = 0usize;
for block in blocks {
let Block::SectionBreak(setup) = block else {
continue;
};
let Some(section_setup) = section_setups.get(section_index) else {
break;
};
let mut section_setup = section_setup.clone();
section_setup.section_break = Some(SectionBreakKind::NextPage);
*setup = section_setup;
section_index = section_index.saturating_add(1);
}
let mut setup = DocSetup::default();
if let Some(final_section) = section_setups.last() {
apply_legacy_section_setup_to_doc_setup(final_section, &mut setup);
}
setup
}
fn apply_legacy_section_setup_to_doc_setup(section: &SectionSetup, setup: &mut DocSetup) {
setup.header = section.header.clone();
setup.first_header = section.first_header.clone();
setup.even_header = section.even_header.clone();
setup.footer = section.footer.clone();
setup.first_footer = section.first_footer.clone();
setup.even_footer = section.even_footer.clone();
}
fn build_legacy_region_blocks(
src: &LegacySource<'_>,
numberer: &mut Numberer<'_>,
fib: &Fib,
table: &[u8],
section_spans: &[LegacySectionSpan],
) -> (Vec<Block>, Vec<SourceRegion>) {
let mut blocks = Vec::new();
let mut regions = Vec::new();
let mut source_start_cp = 0usize;
let mut text_start = 0usize;
let header_stories = header_footer_story_ranges(fib, table);
let has_header_footer_setup_stories = header_stories
.iter()
.any(|story| story.story_index >= HEADER_FOOTER_STORY_BASE);
for (kind, source_len_cp) in legacy_region_specs(fib) {
if kind == SourceRegionKind::Main
&& has_header_footer_setup_stories
&& section_spans.len() > 1
{
push_legacy_main_section_regions(
src,
numberer,
&mut blocks,
&mut regions,
&mut text_start,
source_start_cp,
section_spans,
);
} else if kind == SourceRegionKind::HeaderFooter && has_header_footer_setup_stories {
for story in header_stories
.iter()
.filter(|story| story.story_index >= HEADER_FOOTER_STORY_BASE)
{
push_legacy_region(
src,
numberer,
&mut blocks,
&mut regions,
&mut text_start,
RegionSpec {
kind,
source_start_cp: source_start_cp.saturating_add(story.start_cp),
source_len_cp: story.end_cp.saturating_sub(story.start_cp),
source_story_index: Some(story.story_index),
include_empty: false,
},
);
}
} else {
push_legacy_region(
src,
numberer,
&mut blocks,
&mut regions,
&mut text_start,
RegionSpec {
kind,
source_start_cp,
source_len_cp,
source_story_index: None,
include_empty: kind == SourceRegionKind::Main,
},
);
}
source_start_cp = source_start_cp.saturating_add(source_len_cp);
}
(blocks, regions)
}
fn push_legacy_main_section_regions(
src: &LegacySource<'_>,
numberer: &mut Numberer<'_>,
blocks: &mut Vec<Block>,
regions: &mut Vec<SourceRegion>,
text_start: &mut usize,
source_start_cp: usize,
section_spans: &[LegacySectionSpan],
) {
for (index, span) in section_spans.iter().enumerate() {
push_legacy_region(
src,
numberer,
blocks,
regions,
text_start,
RegionSpec {
kind: SourceRegionKind::Main,
source_start_cp: source_start_cp.saturating_add(span.start_cp),
source_len_cp: span.end_cp.saturating_sub(span.start_cp),
source_story_index: None,
include_empty: true,
},
);
if index + 1 < section_spans.len() {
blocks.push(Block::SectionBreak(legacy_section_break_setup()));
}
}
}
fn legacy_section_break_setup() -> SectionSetup {
SectionSetup {
section_break: Some(SectionBreakKind::NextPage),
..SectionSetup::default()
}
}
fn push_legacy_region(
src: &LegacySource<'_>,
numberer: &mut Numberer<'_>,
blocks: &mut Vec<Block>,
regions: &mut Vec<SourceRegion>,
text_start: &mut usize,
spec: RegionSpec,
) {
let RegionSpec {
kind,
source_start_cp,
source_len_cp,
source_story_index,
include_empty,
} = spec;
let block_start = blocks.len();
let actual_start = source_start_cp.min(src.units.len()).min(src.fcs.len());
let actual_end = source_start_cp
.saturating_add(source_len_cp)
.min(src.units.len())
.min(src.fcs.len());
let mut region_blocks = if actual_start < actual_end {
let mut asm = Asm::new(
src.papx,
src.chpx,
src.stylesheet,
src.data,
src.fonts,
numberer,
);
asm.run(
&src.units[actual_start..actual_end],
&src.fcs[actual_start..actual_end],
);
asm.finish()
} else {
Vec::new()
};
let text_len = compute_stats(®ion_blocks).text_chars;
blocks.append(&mut region_blocks);
let block_end = blocks.len();
if source_len_cp > 0 || include_empty {
regions.push(SourceRegion {
kind,
source_story_index,
block_start,
block_end,
source_start_cp,
source_len_cp,
text_start: *text_start,
text_len,
});
}
*text_start = (*text_start).saturating_add(text_len);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct HeaderStoryRange {
story_index: usize,
start_cp: usize,
end_cp: usize,
}
const HEADER_FOOTER_STORY_BASE: usize = 6;
const FIB_FCLCB_PLCF_SED: usize = 6;
const SED_RECORD_LEN: usize = 12;
fn legacy_header_footer_setup_slot(
setup: &mut DocSetup,
story_index: Option<usize>,
) -> &mut Vec<Block> {
let Some(position) = legacy_header_footer_story_position(story_index) else {
return &mut setup.header;
};
match position {
0 => &mut setup.even_header,
1 => &mut setup.header,
2 => &mut setup.even_footer,
3 => &mut setup.footer,
4 => &mut setup.first_header,
_ => &mut setup.first_footer,
}
}
fn legacy_header_footer_section_setup_slot(
setup: &mut SectionSetup,
story_index: Option<usize>,
) -> Option<&mut Vec<Block>> {
match legacy_header_footer_story_position(story_index)? {
0 => Some(&mut setup.even_header),
1 => Some(&mut setup.header),
2 => Some(&mut setup.even_footer),
3 => Some(&mut setup.footer),
4 => Some(&mut setup.first_header),
_ => Some(&mut setup.first_footer),
}
}
fn legacy_header_footer_story_position(story_index: Option<usize>) -> Option<usize> {
story_index?
.checked_sub(HEADER_FOOTER_STORY_BASE)
.map(|index| index % 6)
}
fn legacy_header_footer_section_index(story_index: Option<usize>) -> Option<usize> {
story_index?
.checked_sub(HEADER_FOOTER_STORY_BASE)
.map(|index| index / 6)
}
fn header_footer_story_ranges(fib: &Fib, table: &[u8]) -> Vec<HeaderStoryRange> {
if fib.ccp_hdd == 0 || fib.lcb_plcf_hdd < 12 {
return Vec::new();
}
let Some(slice) = table.get(fib.fc_plcf_hdd..fib.fc_plcf_hdd.saturating_add(fib.lcb_plcf_hdd))
else {
return Vec::new();
};
let cp_count = slice.len() / 4;
if cp_count < 3 {
return Vec::new();
}
let story_count = cp_count.saturating_sub(2);
let hdd_len = fib.ccp_hdd as usize;
let mut stories = Vec::new();
for story_index in 0..story_count {
let start = u32le(slice, story_index * 4).unwrap_or(0) as usize;
let end = u32le(slice, (story_index + 1) * 4).unwrap_or(0) as usize;
let start = start.min(hdd_len);
let end = end.min(hdd_len);
if start < end {
stories.push(HeaderStoryRange {
story_index,
start_cp: start,
end_cp: end,
});
}
}
stories
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LegacySectionSpan {
start_cp: usize,
end_cp: usize,
}
fn legacy_section_spans(word: &[u8], table: &[u8], main_len_cp: usize) -> Vec<LegacySectionSpan> {
parse_legacy_section_spans(word, table, main_len_cp).unwrap_or_default()
}
fn parse_legacy_section_spans(
word: &[u8],
table: &[u8],
main_len_cp: usize,
) -> Option<Vec<LegacySectionSpan>> {
let (fc, lcb) = fib::fc_lcb_pair(word, FIB_FCLCB_PLCF_SED)?;
if lcb < 4 {
return None;
}
let payload_len = lcb.checked_sub(4)?;
let section_width = 4usize.checked_add(SED_RECORD_LEN)?;
if payload_len % section_width != 0 {
return None;
}
let section_count = payload_len / section_width;
if section_count <= 1 {
return None;
}
let end = fc.checked_add(lcb)?;
let slice = table.get(fc..end)?;
let cp_bytes = section_count.checked_add(1)?.checked_mul(4)?;
if cp_bytes.checked_add(section_count.checked_mul(SED_RECORD_LEN)?)? != lcb {
return None;
}
let mut cps = Vec::with_capacity(section_count + 1);
for index in 0..=section_count {
cps.push(u32le(slice, index * 4)? as usize);
}
if cps.first().copied() != Some(0) || cps.last().copied() != Some(main_len_cp) {
return None;
}
let mut spans = Vec::with_capacity(section_count);
for pair in cps.windows(2) {
let [start_cp, end_cp] = pair else {
return None;
};
if start_cp >= end_cp || *end_cp > main_len_cp {
return None;
}
spans.push(LegacySectionSpan {
start_cp: *start_cp,
end_cp: *end_cp,
});
}
Some(spans)
}
fn legacy_region_specs(fib: &Fib) -> [(SourceRegionKind, usize); 6] {
[
(SourceRegionKind::Main, fib.ccp_text as usize),
(SourceRegionKind::Footnote, fib.ccp_ftn as usize),
(SourceRegionKind::HeaderFooter, fib.ccp_hdd as usize),
(SourceRegionKind::Annotation, fib.ccp_atn as usize),
(SourceRegionKind::Endnote, fib.ccp_edn as usize),
(SourceRegionKind::TextBox, fib.ccp_txbx as usize),
]
}
pub(crate) fn decode_with_fc(
word: &[u8],
pieces: &[Piece],
enc: &'static Encoding,
) -> (Vec<u16>, Vec<u32>) {
let mut units: Vec<u16> = Vec::new();
let mut fcs: Vec<u32> = Vec::new();
let budget = word.len().saturating_add(16);
let mut consumed = 0usize;
for p in pieces {
if p.cch == 0 {
continue;
}
if consumed >= budget {
break;
}
if p.compressed {
let end = p.fc.saturating_add(p.cch).min(word.len());
let Some(slice) = word.get(p.fc..end) else {
continue;
};
consumed = consumed.saturating_add(slice.len());
let text = enc.decode(slice).0;
let mut fc = p.fc as u32;
let mut tmp = [0u8; 4];
let mut ubuf = [0u16; 2];
for ch in text.chars() {
let chs = ch.encode_utf8(&mut tmp);
let (eb, _, had_err) = enc.encode(chs);
let blen = if had_err { 1 } else { eb.len().clamp(1, 2) } as u32;
for u in ch.encode_utf16(&mut ubuf) {
units.push(*u);
fcs.push(fc);
}
fc = fc.saturating_add(blen);
}
} else {
let byte_len = p.cch.saturating_mul(2);
let end = p.fc.saturating_add(byte_len).min(word.len());
let Some(slice) = word.get(p.fc..end) else {
continue;
};
consumed = consumed.saturating_add(slice.len());
for (i, c) in slice.chunks_exact(2).enumerate() {
units.push(u16::from_le_bytes([c[0], c[1]]));
fcs.push((p.fc + i * 2) as u32);
}
}
}
(units, fcs)
}
const CELL_MARK: u16 = 0x07;
const PARA_MARK: u16 = 0x0D;
const FIELD_BEGIN: u16 = 0x13;
const FIELD_SEP: u16 = 0x14;
const FIELD_END: u16 = 0x15;
struct Asm<'a, 'l> {
papx: &'a PapxTable,
chpx: &'a ChpxTable,
stylesheet: &'a StyleSheet,
data: &'a [u8],
fonts: &'a [String],
numberer: &'a mut Numberer<'l>,
blocks: Vec<Block>,
run_buf: Vec<u16>,
run_chp: Chp,
run_props: CharProps,
run_field: FieldRole,
para_runs: Vec<Run_>,
cur_rows: Vec<RowBuild>,
cur_row_cells: Vec<Vec<Block>>,
cell_blocks: Vec<Block>,
field_stack: Vec<FieldState>,
unseparated: usize,
img_cache: HashMap<u32, Image>,
img_budget: usize,
}
#[derive(Default)]
struct FieldState {
separated: bool,
instr_buf: Vec<u16>,
role: FieldRole,
}
use crate::model::Run as Run_;
impl<'a, 'l> Asm<'a, 'l> {
fn new(
papx: &'a PapxTable,
chpx: &'a ChpxTable,
stylesheet: &'a StyleSheet,
data: &'a [u8],
fonts: &'a [String],
numberer: &'a mut Numberer<'l>,
) -> Self {
Asm {
papx,
chpx,
stylesheet,
data,
fonts,
numberer,
blocks: Vec::new(),
run_buf: Vec::new(),
run_chp: Chp::default(),
run_props: CharProps::default(),
run_field: FieldRole::None,
para_runs: Vec::new(),
cur_rows: Vec::new(),
cur_row_cells: Vec::new(),
cell_blocks: Vec::new(),
field_stack: Vec::new(),
unseparated: 0,
img_cache: HashMap::new(),
img_budget: data.len().saturating_mul(2).saturating_add(1 << 20),
}
}
fn in_instruction(&self) -> bool {
self.unseparated != 0
}
fn active_field_role(&self) -> FieldRole {
if self.in_instruction() {
return FieldRole::None;
}
self.field_stack
.last()
.map(|field| field.role.clone())
.unwrap_or_default()
}
fn push_instruction_unit(&mut self, u: u16) {
if let Some(field) = self
.field_stack
.iter_mut()
.rev()
.find(|field| !field.separated)
{
field.instr_buf.push(u);
}
}
fn run(&mut self, units: &[u16], fcs: &[u32]) {
for (i, &u) in units.iter().enumerate() {
let fc = fcs.get(i).copied().unwrap_or(0);
match u {
FIELD_BEGIN => {
self.flush_run();
self.field_stack.push(FieldState::default());
self.unseparated += 1;
}
FIELD_SEP => {
let n = self.field_stack.len();
if n > 0 && !self.field_stack[n - 1].separated {
self.field_stack[n - 1].separated = true;
self.unseparated -= 1;
}
if let Some(field) = self.field_stack.last_mut() {
let instr = String::from_utf16_lossy(&field.instr_buf);
field.role = field_role_from_instruction(&instr);
}
self.flush_run();
}
FIELD_END => {
self.flush_run();
if let Some(field) = self.field_stack.pop() {
if !field.separated {
self.unseparated -= 1;
}
}
}
_ if self.in_instruction() => self.push_instruction_unit(u),
PARA_MARK => self.end_paragraph(fc, false),
CELL_MARK => self.end_paragraph(fc, true),
0x0001 => self.picture(fc),
_ => self.push_content(u, fc),
}
}
}
fn picture(&mut self, fc: u32) {
let Some(fc_pic) = self.chpx.pic_at(fc) else {
return;
};
self.flush_run();
let img = self.extract_image(fc_pic);
self.para_runs.push(Run_ {
text: String::new(),
props: CharProps::default(),
field: FieldRole::None,
field_dirty: false,
field_unsupported_reason: None,
image: Some(img),
comment: None,
revision: None,
content_control: None,
bookmark: None,
note: None,
});
}
fn extract_image(&mut self, fc_pic: u32) -> Image {
if !self.img_cache.contains_key(&fc_pic) {
let lcb = crate::util::u32le(self.data, fc_pic as usize).unwrap_or(0) as usize;
let img = if lcb == 0 || lcb > self.img_budget {
Image::default()
} else {
self.img_budget = self.img_budget.saturating_sub(lcb);
crate::image::extract(self.data, fc_pic)
};
self.img_cache.insert(fc_pic, img);
}
let n = self
.img_cache
.get(&fc_pic)
.and_then(|i| i.bytes.as_ref())
.map_or(0, |b| b.len());
if n == 0 || n > self.img_budget {
let c = self.img_cache.get(&fc_pic).expect("inserted above");
return Image {
alt: c.alt.clone(),
bytes: None,
mime: c.mime.clone(),
width_px: c.width_px,
height_px: c.height_px,
rotation_degrees: c.rotation_degrees,
floating_offset_emu: c.floating_offset_emu,
};
}
self.img_budget -= n;
self.img_cache.get(&fc_pic).cloned().unwrap_or_default()
}
fn push_content(&mut self, u: u16, fc: u32) {
let mapped: Option<u16> = match u {
0x0B | 0x0C | 0x0E => Some(0x000A), 0x1E => Some(0x002D), 0xA0 => Some(0x0020), 0x1F => None, 0x01 | 0x02 | 0x08 => None, c if c < 0x20 && c != b'\t' as u16 => None, c => Some(c),
};
let Some(unit) = mapped else { return };
let chp = self.chpx.chp_at(fc);
if self.run_buf.is_empty() || chp != self.run_chp {
self.flush_run();
self.run_chp = chp;
self.run_props = CharProps {
bold: chp.bold,
italic: chp.italic,
underline: chp.underline,
strike: chp.strike,
hidden: chp.hidden,
size_half_pt: chp.size_half_pt,
color: chp.color,
font: chp.ftc.and_then(|ftc| crate::ffn::name_of(self.fonts, ftc)),
..Default::default()
};
self.run_field = self.active_field_role();
}
self.run_buf.push(unit);
}
fn flush_run(&mut self) {
if self.run_buf.is_empty() {
return;
}
let text = String::from_utf16_lossy(&self.run_buf);
self.run_buf.clear();
self.para_runs.push(Run_ {
text,
props: self.run_props.clone(),
field: self.run_field.clone(),
field_dirty: false,
field_unsupported_reason: None,
image: None,
comment: None,
revision: None,
content_control: None,
bookmark: None,
note: None,
});
}
fn take_paragraph(&mut self, fc: u32) -> Paragraph {
self.flush_run();
let runs = std::mem::take(&mut self.para_runs);
let (ilfo, ilvl) = self.papx.list_at(fc);
let list = if ilfo > 0 {
self.numberer.label(ilfo, ilvl).map(|label| ListInfo {
level: ilvl,
ordered: !label.trim().is_empty(),
label,
})
} else {
None
};
let (istd, outlvl, jc) = self.papx.style_at(fc);
let heading_level = match outlvl {
Some(o) if o <= 8 => Some(o + 1),
Some(_) => None,
None => self.stylesheet.heading_level(istd),
};
let align = match jc {
1 => Align::Center,
2 => Align::Right,
3 | 4 => Align::Justify,
_ => Align::Left,
};
let style_name = self.stylesheet.name(istd).map(str::to_string);
let list = if heading_level.is_some() { None } else { list };
Paragraph {
props: ParaProps {
style_name,
heading_level,
align,
outline_level: outlvl,
list,
..Default::default()
},
runs,
}
}
fn end_paragraph(&mut self, fc: u32, is_cell_mark: bool) {
let (in_table, ttp) = self.papx.at(fc);
let para = self.take_paragraph(fc);
if !in_table {
self.flush_table();
if !para.is_blank() {
self.blocks.push(Block::Paragraph(para));
}
return;
}
if !is_cell_mark {
self.cell_blocks.push(Block::Paragraph(para));
return;
}
let blank_terminator = ttp && para.is_blank() && self.cell_blocks.is_empty();
if !blank_terminator {
self.cell_blocks.push(Block::Paragraph(para));
self.cur_row_cells
.push(std::mem::take(&mut self.cell_blocks));
} else {
self.cell_blocks.clear();
}
if ttp {
let def = self.papx.table_def_at(fc).cloned();
let header = self.papx.table_header_at(fc);
self.cur_rows.push(RowBuild {
cells: std::mem::take(&mut self.cur_row_cells),
def,
header,
});
}
}
fn flush_table(&mut self) {
if !self.cur_row_cells.is_empty() {
self.cur_rows.push(RowBuild {
cells: std::mem::take(&mut self.cur_row_cells),
def: None,
header: false,
});
}
self.cell_blocks.clear();
if !self.cur_rows.is_empty() {
let t = table::build(std::mem::take(&mut self.cur_rows));
if !t.rows.is_empty() {
self.blocks.push(Block::Table(t));
}
}
}
fn finish(mut self) -> Vec<Block> {
if !self.para_runs.is_empty() || !self.run_buf.is_empty() {
let para = self.take_paragraph(u32::MAX);
if !para.is_blank() {
self.blocks.push(Block::Paragraph(para));
}
}
self.flush_table();
self.blocks
}
}
fn parse_hyperlink(instr: &str) -> Option<String> {
crate::annotation::hyperlink_field_target(instr)
}
fn field_role_from_instruction(instr: &str) -> FieldRole {
if let Some(url) = parse_hyperlink(instr) {
return FieldRole::Hyperlink { url };
}
let instruction = normalize_field_instruction(instr);
if instruction.is_empty() {
FieldRole::None
} else {
FieldRole::Simple { instruction }
}
}
pub(crate) fn compute_stats(blocks: &[Block]) -> Stats {
let mut s = Stats::default();
count_blocks(blocks, &mut s);
s
}
fn count_blocks(blocks: &[Block], s: &mut Stats) {
for b in blocks {
match b {
Block::Paragraph(p) => {
s.paragraphs = s.paragraphs.saturating_add(1);
s.text_chars += p.text().chars().count();
for r in &p.runs {
if r.image.is_some() {
s.figures = s.figures.saturating_add(1);
}
}
}
Block::Image(_) => s.figures = s.figures.saturating_add(1),
Block::Chart(_) | Block::PageBreak | Block::SectionBreak(_) => {}
Block::Table(t) => {
s.tables = s.tables.saturating_add(1);
for row in &t.rows {
for cell in &row.cells {
count_blocks(&cell.blocks, s);
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::list::Lists;
fn run_units(units: &[u16]) -> Vec<Block> {
let fcs: Vec<u32> = (0..units.len() as u32).collect();
let papx = PapxTable::default();
let chpx = ChpxTable::default();
let stsh = StyleSheet::default();
let lists = Lists::default();
let mut numberer = Numberer::new(&lists);
let mut asm = Asm::new(&papx, &chpx, &stsh, &[], &[], &mut numberer);
asm.run(units, &fcs);
asm.finish()
}
fn all_text(blocks: &[Block]) -> String {
blocks
.iter()
.filter_map(|b| match b {
Block::Paragraph(p) => Some(p.text()),
_ => None,
})
.collect()
}
fn us(s: &str) -> Vec<u16> {
s.encode_utf16().collect()
}
fn minimal_fib_for_header_footer(ccp_hdd: u32, lcb_plcf_hdd: usize) -> Fib {
Fib {
nfib: 0x00D9,
lid: 0x0409,
encrypted: false,
obfuscated: false,
complex: false,
which_table_stream_one: false,
fc_clx: 0,
lcb_clx: 0,
fc_plcf_bte_papx: 0,
lcb_plcf_bte_papx: 0,
fc_plcf_bte_chpx: 0,
lcb_plcf_bte_chpx: 0,
fc_stshf: 0,
lcb_stshf: 0,
fc_sttbf_ffn: 0,
lcb_sttbf_ffn: 0,
fc_plf_lst: 0,
lcb_plf_lst: 0,
fc_plf_lfo: 0,
lcb_plf_lfo: 0,
fc_plcf_hdd: 0,
lcb_plcf_hdd,
fc_plcfand_ref: 0,
lcb_plcfand_ref: 0,
fc_grp_xst_atn_owners: 0,
lcb_grp_xst_atn_owners: 0,
ccp_text: 0,
ccp_ftn: 0,
ccp_hdd,
ccp_atn: 0,
ccp_edn: 0,
ccp_txbx: 0,
}
}
#[test]
fn field_without_separator_does_not_swallow_following_text() {
let mut units = vec![FIELD_BEGIN];
units.extend(us("AB"));
units.push(FIELD_END);
units.extend(us("CD"));
units.push(PARA_MARK);
assert_eq!(all_text(&run_units(&units)), "CD");
}
#[test]
fn legacy_header_footer_falls_back_when_plcf_hdd_has_no_setup_stories() {
let mut units = us("HDR");
units.push(PARA_MARK);
let fcs: Vec<u32> = (0..units.len() as u32).collect();
let mut plcf_hdd = Vec::new();
for cp in [0u32, units.len() as u32, units.len() as u32] {
plcf_hdd.extend_from_slice(&cp.to_le_bytes());
}
let papx = PapxTable::default();
let chpx = ChpxTable::default();
let stsh = StyleSheet::default();
let lists = Lists::default();
let mut numberer = Numberer::new(&lists);
let fib = minimal_fib_for_header_footer(units.len() as u32, plcf_hdd.len());
let src = LegacySource {
units: &units,
fcs: &fcs,
papx: &papx,
chpx: &chpx,
stylesheet: &stsh,
data: &[],
fonts: &[],
};
let (blocks, regions) =
build_legacy_region_blocks(&src, &mut numberer, &fib, &plcf_hdd, &[]);
let header_region = regions
.iter()
.find(|region| region.kind == SourceRegionKind::HeaderFooter)
.expect("header/footer region should fall back to flat preservation");
assert_eq!(header_region.source_story_index, None);
assert_eq!(header_region.source_start_cp, 0);
assert_eq!(header_region.source_len_cp, units.len());
assert_eq!(header_region.text_len, 3);
assert_eq!(
all_text(&blocks[header_region.block_start..header_region.block_end]),
"HDR"
);
}
#[test]
fn hyperlink_field_result_is_kept_and_linked() {
let mut units = vec![FIELD_BEGIN];
units.extend(us(" HYPERLINK \"http://x\" "));
units.push(FIELD_SEP);
units.extend(us("link"));
units.push(FIELD_END);
units.extend(us(" tail"));
units.push(PARA_MARK);
let blocks = run_units(&units);
let Block::Paragraph(p) = &blocks[0] else {
panic!("expected paragraph");
};
assert_eq!(p.text(), "link tail");
let linked = p
.runs
.iter()
.find(|r| matches!(&r.field, FieldRole::Hyperlink { .. }));
match linked.map(|r| (&r.text, &r.field)) {
Some((t, FieldRole::Hyperlink { url })) => {
assert_eq!(t, "link");
assert_eq!(url, "http://x");
}
other => panic!("expected linked result run, got {other:?}"),
}
let tail = p.runs.iter().find(|r| r.text == " tail").unwrap();
assert_eq!(tail.field, FieldRole::None);
}
#[test]
fn mixed_case_hyperlink_field_result_is_linked() {
let mut units = vec![FIELD_BEGIN];
units.extend(us(" hYpErLiNk \"http://x\" "));
units.push(FIELD_SEP);
units.extend(us("link"));
units.push(FIELD_END);
units.push(PARA_MARK);
let blocks = run_units(&units);
let Block::Paragraph(p) = &blocks[0] else {
panic!("expected paragraph");
};
assert!(matches!(
&p.runs[0].field,
FieldRole::Hyperlink { url } if url == "http://x"
));
assert!(parse_hyperlink(" HYPERLINKBASE \"http://x\" ").is_none());
}
#[test]
fn simple_field_result_keeps_instruction_on_result_run() {
let mut units = vec![FIELD_BEGIN];
units.extend(us(" PAGE "));
units.push(FIELD_SEP);
units.extend(us("7"));
units.push(FIELD_END);
units.extend(us(" tail"));
units.push(PARA_MARK);
let blocks = run_units(&units);
let Block::Paragraph(p) = &blocks[0] else {
panic!("expected paragraph");
};
assert_eq!(p.text(), "7 tail");
let page = p.runs.iter().find(|r| r.text == "7").unwrap();
assert_eq!(
page.field,
FieldRole::Simple {
instruction: "PAGE".to_string()
}
);
let tail = p.runs.iter().find(|r| r.text == " tail").unwrap();
assert_eq!(tail.field, FieldRole::None);
}
#[test]
fn nested_field_returns_to_outer_instruction_then_result() {
let mut units = vec![FIELD_BEGIN];
units.extend(us("A"));
units.push(FIELD_BEGIN);
units.extend(us("B"));
units.push(FIELD_SEP);
units.extend(us("C"));
units.push(FIELD_END);
units.extend(us("D"));
units.push(FIELD_SEP);
units.extend(us("RESULT"));
units.push(FIELD_END);
units.push(PARA_MARK);
assert_eq!(all_text(&run_units(&units)), "RESULT");
}
#[test]
fn many_separated_fields_then_text_stays_linear_and_visible() {
let n = 100_000;
let mut units = Vec::with_capacity(n * 2 + n + 1);
for _ in 0..n {
units.push(FIELD_BEGIN);
units.push(FIELD_SEP);
}
units.resize(units.len() + n, b'A' as u16);
units.push(PARA_MARK);
let text = all_text(&run_units(&units));
assert_eq!(text.len(), n);
assert!(text.chars().all(|c| c == 'A'));
}
#[test]
fn repeated_picture_runs_are_deduped_and_byte_budget_bounds_total() {
let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3];
let payload_len = 33 + png.len();
let lcb = 8 + payload_len;
let mut data = Vec::new();
data.extend_from_slice(&(lcb as u32).to_le_bytes());
data.extend_from_slice(&8u16.to_le_bytes());
data.extend_from_slice(&[0u8; 2]); data.extend_from_slice(&[0u8; 33]); data.extend_from_slice(&png);
let papx = PapxTable::default();
let chpx = ChpxTable::default();
let stsh = StyleSheet::default();
let lists = Lists::default();
let mut numberer = Numberer::new(&lists);
let mut asm = Asm::new(&papx, &chpx, &stsh, &data, &[], &mut numberer);
let first = asm.extract_image(0);
assert!(first.bytes.is_some(), "first extraction finds the PNG");
for _ in 0..50 {
let _ = asm.extract_image(0);
}
assert_eq!(asm.img_cache.len(), 1, "same fcPic scanned/cached once");
let img_bytes = first.bytes.as_ref().unwrap().len();
asm.img_budget = img_bytes; assert!(asm.extract_image(0).bytes.is_some());
let over = asm.extract_image(0);
assert!(over.bytes.is_none(), "over-budget picture is a placeholder");
assert_eq!(
over.mime.as_deref(),
Some("image/png"),
"placeholder keeps mime"
);
}
#[test]
fn same_property_content_coalesces_into_one_run() {
let mut units = us("HELLOWORLD");
units.push(PARA_MARK);
let blocks = run_units(&units);
let Block::Paragraph(p) = &blocks[0] else {
panic!("expected paragraph");
};
assert_eq!(p.runs.len(), 1);
assert_eq!(p.runs[0].text, "HELLOWORLD");
}
#[test]
fn decode_with_fc_keeps_fc_aligned_past_an_undecodable_byte() {
use crate::clx::Piece;
let base = 0x200usize;
let mut word = vec![0u8; base];
word.extend_from_slice(&[b'A', 0x81, b'B']);
let pieces = [Piece {
cch: 3,
fc: base,
compressed: true,
}];
let (units, fcs) = decode_with_fc(&word, &pieces, encoding_rs::WINDOWS_1252);
assert_eq!(units.len(), 3);
assert_eq!(fcs, vec![base as u32, base as u32 + 1, base as u32 + 2]);
assert_eq!(units[0], b'A' as u16);
assert_eq!(units[2], b'B' as u16);
}
#[test]
fn hyperlink_instruction_parsing() {
assert_eq!(
parse_hyperlink(" HYPERLINK \"https://example.com\" \\o \"tip\" ").as_deref(),
Some("https://example.com")
);
assert_eq!(parse_hyperlink(" PAGE "), None);
assert_eq!(
parse_hyperlink(" HYPERLINK \\l \"anchor\" ").as_deref(),
Some("anchor")
);
assert_eq!(parse_hyperlink(" HYPERLINK \\o \"tip\" "), None);
assert_eq!(
parse_hyperlink(" HYPERLINK \"https://example.com\" \"extra "),
None
);
assert_eq!(
parse_hyperlink(" HYPERLINK \"https://example.com\" extra "),
None
);
}
}