diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d53b6ce..b749633b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ The workspace `Cargo.toml` `[workspace.package] version` is the single source fo **Fixed** +- Section, saved-page and typed-range splits exclude the source's whole HWP5 `/DocHistory` + from every fragment and ledger the removed storage and its streams. `--strict` refuses this + reported loss before fragment publication. Native XMLTemplate and HWPX extra parts remain + unchanged to preserve opaque schema/package dependencies; ordinary convert, edit and identity + round-trips retain their existing history behavior ([#449](https://github.com/STAIxBWLB/hwp-cli/issues/449)). + - Shared stage-sync and file-identity helper failures now name their phase and advise checking output-directory permissions and free space before rerunning. The helper error values use fixed Korean messages without staging/destination paths or underlying I/O error chains, diff --git a/crates/hwp-cli/src/commands/split.rs b/crates/hwp-cli/src/commands/split.rs index 5fe3e1b8..a086c6a5 100644 --- a/crates/hwp-cli/src/commands/split.rs +++ b/crates/hwp-cli/src/commands/split.rs @@ -468,6 +468,7 @@ pub(crate) fn execute( SplitOutcome { fragments: vec![extracted.fragment.clone()], roundings: Vec::new(), + preservation: extracted.preservation.clone(), } } else if pages.is_empty() { hwp_convert::document_split::split_sections(&document) @@ -531,6 +532,9 @@ pub(crate) fn execute( .with_context(|| format!("출력 디렉터리를 만들 수 없습니다: {}", out_dir.display()))?; let mut report = WriteReport::new(); + // Writers compare the sanitized fragment with its output and cannot see source history + // intentionally excluded by the transform. Carry that evidence into strict/loss reporting. + report.preservation.extend(outcome.preservation.clone()); let mut fragment_bytes: Vec<(PathBuf, Vec)> = Vec::with_capacity(outcome.fragments.len()); for (fragment, destination) in outcome.fragments.iter().zip(&destinations) { let (write_report, output_document, bytes) = diff --git a/crates/hwp-cli/tests/split_side_storages.rs b/crates/hwp-cli/tests/split_side_storages.rs new file mode 100644 index 00000000..14dd471e --- /dev/null +++ b/crates/hwp-cli/tests/split_side_storages.rs @@ -0,0 +1,376 @@ +//! Synthetic split side-storage policy and preservation reporting (#449). +//! Opaque sentinels exercise container passthrough, not history/schema record parsing. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use hwp_model::{ + Document, LineSeg, PreservationCode, PreservationDisposition, PreservationReport, + PreservationResourceKind, +}; + +#[path = "common/temp_path.rs"] +mod temp_path; + +const HISTORY_A: &[u8] = b"PRIVATE_HISTORY_449_VERSION_A"; +const HISTORY_B: &[u8] = b"PRIVATE_HISTORY_449_VERSION_B"; +const SCHEMA: &[u8] = b"opaque owner-authored schema sentinel"; + +#[derive(Clone, Copy)] +enum Selection { + Sections, + Page, + Range, +} + +struct Workspace { + dir: temp_path::TempPath, + input: PathBuf, + out: PathBuf, + loss: PathBuf, + range: PathBuf, + source_bytes: Vec, +} + +fn source_document(history: bool) -> Document { + let mut doc = hwp_convert::from_markdown("First section\n\nSelected body\n\nLast body\n"); + assert_eq!(doc.sections[0].paragraphs.len(), 3); + doc.sections.push(doc.sections[0].clone()); + doc.header.properties.section_count = 2; + // Explicit synthetic page-start flags drive page selection; no font-derived page count. + for section in &mut doc.sections { + for (index, paragraph) in section.paragraphs.iter_mut().enumerate() { + paragraph.line_segs = vec![LineSeg { + text_start: 0, + v_pos: if index == 2 { 1600 } else { 0 }, + line_height: 1000, + text_height: 1000, + baseline_gap: 850, + line_spacing: 600, + col_start: 0, + seg_width: 10000, + flags: u32::from(index < 2), + }]; + } + } + doc.hwp5_xml_template = vec![("/XMLTemplate/Schema".into(), SCHEMA.to_vec())]; + if history { + doc.hwp5_doc_history = vec![ + ("/DocHistory/VersionLog0".into(), HISTORY_A.to_vec()), + ("/DocHistory/VersionLog1".into(), HISTORY_B.to_vec()), + ]; + } + doc +} + +impl Workspace { + fn new(label: &str, history: bool) -> Self { + let dir = temp_path::TempPath::new("hwp449", label); + std::fs::create_dir_all(&*dir).unwrap(); + let input = dir.join("source.hwp"); + hwp5::write_document( + &source_document(history), + &input, + &hwp5::WriteOptions { + preserve_linesegs: true, + ..Default::default() + }, + ) + .unwrap(); + let read = hwp5::read_document(&input).unwrap().document; + assert_eq!(read.hwp5_doc_history.len(), if history { 2 } else { 0 }); + assert_eq!( + hwp_convert::document_split::page_start_addresses(&read).len(), + 4, + "the fixture must retain its four explicit saved-page markers" + ); + let source_bytes = std::fs::read(&input).unwrap(); + Self { + out: dir.join("out"), + loss: dir.join("loss.json"), + range: dir.join("range.json"), + dir, + input, + source_bytes, + } + } + + fn split(&self, selection: Selection, strict: bool) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_hwp")); + command + .arg("split") + .arg(&self.input) + .arg("--out-dir") + .arg(&self.out) + .arg("--loss-report") + .arg(&self.loss); + match selection { + Selection::Sections => {} + Selection::Page => { + command.args(["--pages", "2"]); + } + Selection::Range => { + command + .args(["--from", "at:0.1", "--to", "at:0.2", "--range-report"]) + .arg(&self.range); + } + } + if strict { + command.arg("--strict"); + } + command.output().unwrap() + } + + fn fragments(&self) -> Vec { + let entries = match std::fs::read_dir(&self.out) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Vec::new(), + Err(error) => panic!("cannot inspect fragment output: {error}"), + }; + let mut paths: Vec<_> = entries + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|extension| extension == "hwp")) + .collect(); + paths.sort(); + paths + } + + fn report(&self) -> PreservationReport { + serde_json::from_slice(&std::fs::read(&self.loss).unwrap()).unwrap() + } + + fn assert_source_unchanged(&self) { + assert_eq!(std::fs::read(&self.input).unwrap(), self.source_bytes); + } +} + +fn removed_count(report: &PreservationReport, code: PreservationCode) -> usize { + report + .events + .iter() + .filter(|event| event.code == code && event.disposition == PreservationDisposition::Removed) + .map(|event| event.count) + .sum() +} + +fn assert_no_history(path: &Path) { + let mut container = hwp5::Hwp5Container::open(path).unwrap(); + assert!( + !container + .list_storages() + .iter() + .any(|name| name == "/DocHistory" || name.starts_with("/DocHistory/")), + "a split fragment must not retain the source's whole DocHistory storage" + ); + let bytes = std::fs::read(path).unwrap(); + for history in [HISTORY_A, HISTORY_B] { + assert!( + !bytes.windows(history.len()).any(|window| window == history), + "excluded revision bytes must not remain in the fragment container" + ); + } + assert_eq!( + container.read_stream_raw("/XMLTemplate/Schema").unwrap(), + SCHEMA + ); + assert!( + hwp5::read_document(path) + .unwrap() + .document + .hwp5_doc_history + .is_empty() + ); +} + +fn assert_history_omissions(selection: Selection, label: &str, expected_fragments: usize) { + let workspace = Workspace::new(label, true); + let result = workspace.split(selection, false); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let fragments = workspace.fragments(); + assert_eq!(fragments.len(), expected_fragments); + for fragment in fragments { + assert_no_history(&fragment); + } + let report = workspace.report(); + assert_eq!( + removed_count(&report, PreservationCode::HwpContainerStorageRemoved), + expected_fragments + ); + assert_eq!( + removed_count(&report, PreservationCode::HwpContainerStreamRemoved), + expected_fragments * 2 + ); + assert!(report.events.iter().all(|event| matches!( + event.resource, + PreservationResourceKind::ContainerStorage | PreservationResourceKind::ContainerStream + ))); + workspace.assert_source_unchanged(); +} + +#[test] +fn section_fragments_drop_history_and_report_omissions() { + assert_history_omissions(Selection::Sections, "sections", 2); +} + +#[test] +fn saved_page_fragments_drop_history_and_report_omissions() { + assert_history_omissions(Selection::Page, "pages", 1); +} + +#[test] +fn typed_range_fragments_drop_history_and_report_omissions() { + assert_history_omissions(Selection::Range, "range", 1); +} + +#[test] +fn history_omissions_are_reported_before_strict_refusal() { + let workspace = Workspace::new("strict", true); + let result = workspace.split(Selection::Sections, true); + assert!( + !result.status.success(), + "strict split must refuse its intentional history loss" + ); + assert_eq!( + removed_count( + &workspace.report(), + PreservationCode::HwpContainerStorageRemoved + ), + 2 + ); + assert_eq!( + removed_count( + &workspace.report(), + PreservationCode::HwpContainerStreamRemoved + ), + 4 + ); + assert!(workspace.fragments().is_empty()); + workspace.assert_source_unchanged(); +} + +#[test] +fn empty_history_keeps_schema_and_has_no_storage_omission() { + let workspace = Workspace::new("empty", false); + let result = workspace.split(Selection::Range, false); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!( + removed_count( + &workspace.report(), + PreservationCode::HwpContainerStorageRemoved + ), + 0 + ); + assert_eq!( + removed_count( + &workspace.report(), + PreservationCode::HwpContainerStreamRemoved + ), + 0 + ); + for fragment in workspace.fragments() { + assert_no_history(&fragment); + } + let report: serde_json::Value = + serde_json::from_slice(&std::fs::read(&workspace.range).unwrap()).unwrap(); + let schema: serde_json::Value = serde_json::from_str(include_str!( + "../../../schemas/range-extraction-v1.schema.json" + )) + .unwrap(); + assert!( + jsonschema::validator_for(&schema) + .unwrap() + .is_valid(&report), + "storage accounting must not change the frozen range report" + ); +} + +#[test] +fn ordinary_convert_edit_and_identity_keep_history() { + let workspace = Workspace::new("ordinary", true); + let original = hwp5::read_document(&workspace.input).unwrap().document; + let identity = workspace.dir.join("identity.hwp"); + hwp5::rewrite_document_with_report( + &workspace.input, + &original, + &original, + &identity, + &hwp5::WriteOptions::default(), + ) + .unwrap(); + assert_eq!(std::fs::read(&identity).unwrap(), workspace.source_bytes); + for (command, output) in [("convert", "converted.hwp"), ("edit", "edited.hwp")] { + let output = workspace.dir.join(output); + let mut process = Command::new(env!("CARGO_BIN_EXE_hwp")); + process + .arg(command) + .arg(&workspace.input) + .arg("-o") + .arg(&output); + if command == "edit" { + process.args(["--replace", "Selected body=>Edited body"]); + } + let result = process.output().unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let document = hwp5::read_document(&output).unwrap().document; + assert_eq!(document.hwp5_doc_history, original.hwp5_doc_history); + assert_eq!(document.hwp5_xml_template, original.hwp5_xml_template); + } + workspace.assert_source_unchanged(); +} + +#[test] +fn hwpx_extra_parts_and_manifest_entries_remain_paired() { + let dir = temp_path::TempPath::new("hwp449", "extra"); + std::fs::create_dir_all(&*dir).unwrap(); + let mut document = source_document(false); + document.hwp5_xml_template.clear(); + let payload = b""; + document.hwpx_extra_entries = vec![("DocOptions/Layout.xml".into(), payload.to_vec())]; + document.hwpx_opf_extra_items = vec![( + "layout-part".into(), + "DocOptions/Layout.xml".into(), + "application/xml".into(), + )]; + let source = dir.join("source.hwpx"); + hwpx::write_document(&document, &source).unwrap(); + let output = dir.join("out"); + let result = Command::new(env!("CARGO_BIN_EXE_hwp")) + .arg("split") + .arg(&source) + .arg("--out-dir") + .arg(&output) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let fragment = hwpx::read_document(&output.join("source-001.hwpx")) + .unwrap() + .document; + assert!( + fragment + .hwpx_extra_entries + .iter() + .any(|(name, bytes)| name == "DocOptions/Layout.xml" && bytes == payload) + ); + assert!( + fragment + .hwpx_opf_extra_items + .iter() + .any(|(_, href, _)| href == "DocOptions/Layout.xml") + ); +} diff --git a/crates/hwp-convert/src/document_split.rs b/crates/hwp-convert/src/document_split.rs index 1bd278e2..0762c97f 100644 --- a/crates/hwp-convert/src/document_split.rs +++ b/crates/hwp-convert/src/document_split.rs @@ -38,13 +38,22 @@ //! body paragraph and table gets a source-to-output address so a later binding does not guess //! shifted indices. //! +//! **Side-storage policy (#449).** Every fragment excludes the source's whole HWP5 DocHistory. +//! Its omitted storage and streams are returned in the typed preservation ledger. XMLTemplate +//! remains opaque native-form binding data; HWPX extra entries and their OPF registrations remain +//! paired because retained opaque content can depend on master pages, scripts or extension parts. +//! Those retained global parts can still contain document-wide information. +//! //! This module must not depend on the sibling merge-grafting module: D-06's //! "whole DocHeader unchanged" is exactly the direction that needs no offset //! arithmetic. use std::collections::BTreeSet; -use hwp_model::{BinRef, Control, Document, HwpChar, Paragraph, Section}; +use hwp_model::{ + BinRef, Control, Document, HwpChar, Paragraph, PreservationCode, PreservationDisposition, + PreservationEvent, PreservationReport, PreservationResourceKind, Section, +}; use sha2::{Digest as _, Sha256}; /// A 1-based, inclusive page range for [`split_page_ranges`]. @@ -81,21 +90,50 @@ pub struct SplitOutcome { pub fragments: Vec, /// Every D-08 boundary rounding applied while building the fragments. pub roundings: Vec, + /// Intentional side-storage losses, counted once for each produced fragment. + pub preservation: PreservationReport, +} + +/// Excludes whole-document revision history from an owned fragment only. +/// +/// Counts match IR-level cross-format accounting: one populated storage slot and its modeled +/// streams. The bytes are opaque and never interpreted or copied into public diagnostics. +/// XMLTemplate and HWPX package parts stay unchanged to retain schema/part dependencies. +fn drop_fragment_history(fragment: &mut Document) -> PreservationReport { + let mut preservation = PreservationReport::new(); + let streams = fragment.hwp5_doc_history.len(); + if streams != 0 { + fragment.hwp5_doc_history.clear(); + preservation.record(PreservationEvent::new( + PreservationCode::HwpContainerStorageRemoved, + PreservationResourceKind::ContainerStorage, + PreservationDisposition::Removed, + 1, + )); + preservation.record(PreservationEvent::new( + PreservationCode::HwpContainerStreamRemoved, + PreservationResourceKind::ContainerStream, + PreservationDisposition::Removed, + streams, + )); + } + preservation } /// Divides `doc` into one fragment per `Section`, in document order (D-05). /// -/// Each fragment carries the source's whole `DocHeader`, `metadata` and every -/// singular package-passthrough field unchanged (D-06) — only `sections`, -/// `header.properties.section_count` (set to 1) and `header.properties.caret` -/// (zeroed, since it may otherwise reference a list/paragraph id outside the -/// fragment's own body) are fragment-specific. +/// Each fragment keeps the source's whole `DocHeader`, metadata and retained package/form +/// fields (D-06), while excluding whole-document revision history. +/// Only `sections`, `header.properties.section_count` (set to 1) and +/// `header.properties.caret` (zeroed because it can reference an excluded list/paragraph) are +/// otherwise fragment-specific. History omissions are returned in the preservation ledger. /// /// Returns `Err` when `doc` has no sections. pub fn split_sections(doc: &Document) -> Result { if doc.sections.is_empty() { return Err("문서에 구역이 없어 분할할 수 없습니다".to_string()); } + let mut preservation = PreservationReport::new(); let fragments = doc .sections .iter() @@ -104,12 +142,14 @@ pub fn split_sections(doc: &Document) -> Result { fragment.sections = vec![section.clone()]; fragment.header.properties.section_count = 1; fragment.header.properties.caret = (0, 0, 0); + preservation.extend(drop_fragment_history(&mut fragment)); fragment }) .collect(); Ok(SplitOutcome { fragments, roundings: Vec::new(), + preservation, }) } @@ -237,6 +277,7 @@ pub fn split_page_ranges(doc: &Document, ranges: &[PageRange]) -> Result Result Result, pub resources: ResourceClosureReport, + /// Side-storage losses reported through the caller's preservation ledger. + pub preservation: PreservationReport, /// The source paragraph range that was taken: `[start, end)` in `section`. pub section: usize, pub start: usize, @@ -1507,6 +1552,7 @@ pub fn extract_range( }]; fragment.header.properties.section_count = 1; fragment.header.properties.caret = (0, 0, 0); + let preservation = drop_fragment_history(&mut fragment); fix_fragment_last_paragraph_flags(&mut fragment); // The closure runs over the fragment as built, with the whole header and the carried setup @@ -1556,6 +1602,7 @@ pub fn extract_range( fragment, address_map, resources: report, + preservation, section: section_index, start, end,