use super::piece_table::{Piece, ReconstructedText};
#[derive(Debug, Clone, Default)]
pub struct ParagraphProp {
pub start_fc: u32,
pub istd: u16,
pub f_in_table: bool,
pub f_tt_p: bool,
pub page_break_before: bool,
pub ilfo: u16,
pub ilvl: u8,
}
const SPRM_P_F_IN_TABLE: u16 = 0x2416;
const SPRM_P_F_TTP: u16 = 0x2417;
const SPRM_P_F_PAGE_BREAK_BEFORE: u16 = 0x2407;
const SPRM_P_ILFO: u16 = 0x460B;
const SPRM_P_ILVL: u16 = 0x260A;
const FKP_SIZE: usize = 512;
const BXPAP_SIZE: usize = 13;
fn read_u16(data: &[u8], offset: usize) -> Option<u16> {
data.get(offset..offset + 2)
.map(|b| u16::from_le_bytes([b[0], b[1]]))
}
fn read_u32(data: &[u8], offset: usize) -> Option<u32> {
data.get(offset..offset + 4)
.map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
fn parse_grpprl(grpprl: &[u8], prop: &mut ParagraphProp) {
let mut p = 0usize;
while p + 2 <= grpprl.len() {
let opcode = u16::from_le_bytes([grpprl[p], grpprl[p + 1]]);
p += 2;
let spra = (opcode >> 13) & 0x7;
let operand_len: usize;
let variable = spra == 6;
if variable {
if p >= grpprl.len() {
break;
}
let cb_var = grpprl[p] as usize;
p += 1;
operand_len = cb_var;
} else {
operand_len = match spra {
0 | 1 => 1,
2 | 4 | 5 => 2,
3 => 4,
7 => 3,
_ => 0,
};
}
let end = match p.checked_add(operand_len) {
Some(v) => v,
None => break,
};
let op = match grpprl.get(p..end) {
Some(v) => v,
None => break,
};
match opcode {
SPRM_P_F_IN_TABLE if !op.is_empty() => {
prop.f_in_table = (op[0] & 0x01) != 0;
}
SPRM_P_F_TTP if !op.is_empty() => {
prop.f_tt_p = (op[0] & 0x01) != 0;
}
SPRM_P_F_PAGE_BREAK_BEFORE if !op.is_empty() => {
prop.page_break_before = (op[0] & 0x01) != 0;
}
SPRM_P_ILFO if op.len() >= 2 => {
prop.ilfo = u16::from_le_bytes([op[0], op[1]]);
}
SPRM_P_ILVL if !op.is_empty() => {
prop.ilvl = op[0];
}
_ => {}
}
p = end;
}
}
fn parse_papx_in_fkp(page: &[u8], pos: usize) -> Option<(u16, &[u8])> {
let cb = *page.get(pos)? as usize;
let (record_start, record_len) = if cb != 0 {
(pos + 1, (2 * cb).checked_sub(1)?)
} else {
let cb2 = *page.get(pos + 1)? as usize;
(pos + 2, 2 * cb2)
};
if record_len < 2 {
return None;
}
let istd = read_u16(page, record_start)?;
let grpprl_start = record_start + 2;
let grpprl_end = grpprl_start.checked_add(record_len - 2)?.min(page.len());
Some((istd, page.get(grpprl_start..grpprl_end).unwrap_or(&[])))
}
pub fn parse_paragraph_props(
word_doc: &[u8],
table_stream: &[u8],
fc_plcf_papx: u32,
lcb_plcf_papx: u32,
) -> Result<Vec<ParagraphProp>, String> {
let start = fc_plcf_papx as usize;
let len = lcb_plcf_papx as usize;
if len < 8 {
return Ok(Vec::new());
}
let end = start
.checked_add(len)
.ok_or_else(|| "PlcfBtePapx offset overflow".to_string())?;
let plcf = table_stream
.get(start..end)
.ok_or_else(|| "PlcfBtePapx points outside table stream".to_string())?;
let n = len.saturating_sub(4) / 8;
if n == 0 {
return Ok(Vec::new());
}
let pn_start = (n + 1) * 4;
let mut props = Vec::new();
for i in 0..n {
let pn = match read_u32(plcf, pn_start + i * 4) {
Some(v) => v as usize,
None => continue,
};
let page_start = match pn.checked_mul(FKP_SIZE) {
Some(v) => v,
None => continue,
};
let page = match word_doc.get(page_start..page_start + FKP_SIZE) {
Some(v) => v,
None => continue,
};
let cpara = page[FKP_SIZE - 1] as usize;
let rgbx_start = (cpara + 1) * 4;
if rgbx_start + cpara * BXPAP_SIZE > FKP_SIZE - 1 {
continue;
}
for j in 0..cpara {
let start_fc = match read_u32(page, j * 4) {
Some(v) => v,
None => continue,
};
let b_offset = page[rgbx_start + j * BXPAP_SIZE] as usize;
if b_offset == 0 {
continue;
}
let Some((istd, grpprl)) = parse_papx_in_fkp(page, b_offset * 2) else {
continue;
};
let mut prop = ParagraphProp {
start_fc,
istd,
..Default::default()
};
parse_grpprl(grpprl, &mut prop);
props.push(prop);
}
}
props.sort_by_key(|p| p.start_fc);
Ok(props)
}
fn fc_to_cp(pieces: &[Piece], fc: usize) -> Option<u32> {
for piece in pieces {
let chars = (piece.cp_end - piece.cp_start) as usize;
let width = if piece.compressed { 1 } else { 2 };
let byte_len = chars.checked_mul(width)?;
if fc >= piece.fc && fc < piece.fc + byte_len {
return Some(piece.cp_start + ((fc - piece.fc) / width) as u32);
}
}
None
}
pub fn index_by_paragraph(
props: &[ParagraphProp],
pieces: &[Piece],
story: &ReconstructedText,
) -> Vec<Option<ParagraphProp>> {
let mut out = vec![None; story.paragraph_start_cps.len()];
for prop in props {
let Some(cp) = fc_to_cp(pieces, prop.start_fc as usize) else {
continue;
};
let Some(idx) = story.paragraph_of_cp(cp) else {
continue;
};
if let Some(slot) = out.get_mut(idx) {
*slot = Some(prop.clone());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn fkp_document(entries: &[(u32, Vec<u8>)]) -> (Vec<u8>, Vec<u8>) {
let cpara = entries.len();
let mut page = vec![0u8; FKP_SIZE];
let mut write_at = (cpara + 1) * 4 + cpara * BXPAP_SIZE;
write_at += write_at % 2;
for (j, (fc, papx)) in entries.iter().enumerate() {
page[j * 4..j * 4 + 4].copy_from_slice(&fc.to_le_bytes());
page[(cpara + 1) * 4 + j * BXPAP_SIZE] = (write_at / 2) as u8;
page[write_at..write_at + papx.len()].copy_from_slice(papx);
write_at += papx.len();
write_at += write_at % 2;
}
let last = entries.last().map(|(fc, _)| fc + 1).unwrap_or(0);
page[cpara * 4..cpara * 4 + 4].copy_from_slice(&last.to_le_bytes());
page[FKP_SIZE - 1] = cpara as u8;
let mut word_doc = vec![0u8; FKP_SIZE * 2];
word_doc[FKP_SIZE..].copy_from_slice(&page);
let mut plcf = Vec::new();
plcf.extend_from_slice(&0u32.to_le_bytes());
plcf.extend_from_slice(&u32::MAX.to_le_bytes());
plcf.extend_from_slice(&1u32.to_le_bytes());
(word_doc, plcf)
}
fn papx(istd: u16, sprms: &[u8]) -> Vec<u8> {
let mut body = istd.to_le_bytes().to_vec();
body.extend_from_slice(sprms);
if body.len() % 2 == 0 {
body.push(0);
}
let cb = (body.len() + 1) / 2;
let mut out = vec![cb as u8];
out.extend_from_slice(&body);
out
}
fn sprm_u8(opcode: u16, value: u8) -> Vec<u8> {
let mut v = opcode.to_le_bytes().to_vec();
v.push(value);
v
}
fn sprm_u16(opcode: u16, value: u16) -> Vec<u8> {
let mut v = opcode.to_le_bytes().to_vec();
v.extend_from_slice(&value.to_le_bytes());
v
}
#[test]
fn a_single_fkp_entry_is_found_at_all() {
let (word_doc, plcf) = fkp_document(&[(100, papx(7, &[]))]);
let props = parse_paragraph_props(&word_doc, &plcf, 0, plcf.len() as u32).unwrap();
assert_eq!(props.len(), 1, "the one PAPX entry must be read");
assert_eq!(props[0].istd, 7);
assert_eq!(props[0].start_fc, 100);
}
#[test]
fn sprms_decode_table_list_and_page_break_flags() {
let mut sprms = sprm_u8(SPRM_P_F_IN_TABLE, 1);
sprms.extend(sprm_u8(SPRM_P_F_TTP, 1));
sprms.extend(sprm_u8(SPRM_P_F_PAGE_BREAK_BEFORE, 1));
sprms.extend(sprm_u16(SPRM_P_ILFO, 3));
sprms.extend(sprm_u8(SPRM_P_ILVL, 2));
let (word_doc, plcf) = fkp_document(&[(0, papx(1, &sprms))]);
let props = parse_paragraph_props(&word_doc, &plcf, 0, plcf.len() as u32).unwrap();
assert_eq!(props.len(), 1);
let p = &props[0];
assert!(p.f_in_table, "sprmPFInTable");
assert!(p.f_tt_p, "sprmPFTtp");
assert!(p.page_break_before, "sprmPFPageBreakBefore");
assert_eq!(p.ilfo, 3, "sprmPIlfo");
assert_eq!(p.ilvl, 2, "sprmPIlvl — the list nesting level (#12)");
}
#[test]
fn grpprl_length_covers_every_sprm_in_the_record() {
let mut sprms = sprm_u16(SPRM_P_ILFO, 9);
sprms.extend(sprm_u8(SPRM_P_ILVL, 4));
sprms.extend(sprm_u8(SPRM_P_F_IN_TABLE, 0));
sprms.extend(sprm_u8(SPRM_P_F_TTP, 0));
sprms.extend(sprm_u8(SPRM_P_F_PAGE_BREAK_BEFORE, 1));
let (word_doc, plcf) = fkp_document(&[(0, papx(2, &sprms))]);
let props = parse_paragraph_props(&word_doc, &plcf, 0, plcf.len() as u32).unwrap();
assert!(props[0].page_break_before, "trailing sprm was truncated away");
assert_eq!(props[0].ilvl, 4);
}
#[test]
fn cb_zero_records_read_their_length_from_the_next_byte() {
let sprms = sprm_u8(SPRM_P_ILVL, 5);
let mut body = 3u16.to_le_bytes().to_vec();
body.extend_from_slice(&sprms);
if body.len() % 2 != 0 {
body.push(0);
}
let mut record = vec![0u8, (body.len() / 2) as u8];
record.extend_from_slice(&body);
let (word_doc, plcf) = fkp_document(&[(0, record)]);
let props = parse_paragraph_props(&word_doc, &plcf, 0, plcf.len() as u32).unwrap();
assert_eq!(props[0].istd, 3);
assert_eq!(props[0].ilvl, 5);
}
#[test]
fn properties_land_on_the_paragraph_they_describe() {
let word_doc = b"abc\rdef\xffghi\r".to_vec();
let mut word_doc = word_doc;
word_doc[7] = 0x07;
let pieces = vec![Piece {
cp_start: 0,
cp_end: 12,
fc: 0,
compressed: true,
}];
let story = super::super::piece_table::reconstruct_from_pieces(&word_doc, &pieces);
assert_eq!(story.paragraph_start_cps, vec![0, 4, 8, 12]);
let props = vec![
ParagraphProp { start_fc: 0, istd: 1, ..Default::default() },
ParagraphProp { start_fc: 4, istd: 2, ..Default::default() },
ParagraphProp { start_fc: 8, istd: 3, ..Default::default() },
];
let indexed = index_by_paragraph(&props, &pieces, &story);
let istds: Vec<Option<u16>> = indexed.iter().map(|p| p.as_ref().map(|p| p.istd)).collect();
assert_eq!(istds, vec![Some(1), Some(2), Some(3), None]);
}
#[test]
fn properties_from_another_story_are_not_borrowed() {
let word_doc = b"abc\rdef\r".to_vec();
let pieces = vec![Piece {
cp_start: 0,
cp_end: 8,
fc: 0,
compressed: true,
}];
let story = super::super::piece_table::reconstruct_from_pieces(&word_doc, &pieces);
let props = vec![ParagraphProp { start_fc: 9_000, istd: 4, ..Default::default() }];
let indexed = index_by_paragraph(&props, &pieces, &story);
assert!(indexed.iter().all(Option::is_none));
}
}