1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
//! `hwpforge_inspect` — HWPX document structure inspection tool.
use serde::Serialize;
use hwpforge::ops::{self, InspectOptions};
use crate::compat::{self, Tool};
use crate::output::{read_file_bytes, ToolErrorInfo, ToolWarningInfo};
/// Summary of a single section.
#[derive(Debug, Serialize)]
pub struct SectionDetail {
/// Section index (0-based).
pub index: usize,
/// Number of paragraphs.
pub paragraphs: usize,
/// Number of tables.
pub tables: usize,
/// Number of images.
pub images: usize,
/// Number of charts.
pub charts: usize,
/// Whether header is present.
pub has_header: bool,
/// Whether footer is present.
pub has_footer: bool,
/// Whether page number is present.
pub has_page_number: bool,
// The eight fields above are this struct's original top-level-only
// contract; the nine below carry `ops::InspectSection`'s `all_`/`deep_`
// counts through under the same names and values. They stay at the end,
// after the eight, so a client reading fields positionally is
// unaffected — `inspect_section_detail_key_order_is_existing_eight_then_new_nine`
// locks that.
//
// `ops::InspectSection`'s `# Scope table` is the canonical statement of
// what each prefix counts; the docs below name a scope rather than
// restating it.
/// Tables, `all_` scope — a decoded-object count, not a raw scan.
pub all_tables: usize,
/// Images, `all_` scope.
pub all_images: usize,
/// Text boxes (HWPX `<hp:rect>` with a nested `<hp:drawText>`), `all_`
/// scope.
pub all_text_boxes: usize,
/// Line drawing objects, `all_` scope.
pub all_lines: usize,
/// Pure rectangles, `all_` scope — a text-bearing one counts under
/// [`Self::all_text_boxes`] instead, never both.
pub all_rectangles: usize,
/// Polygon drawing objects, `all_` scope.
pub all_polygons: usize,
/// Body-flow paragraphs carrying visible text — [`Self::paragraphs`]'s
/// set, narrowed.
pub top_level_non_empty_paragraphs: usize,
/// Paragraphs, `deep_` scope.
pub deep_paragraphs: usize,
/// Paragraphs carrying visible text, `deep_` scope.
pub deep_non_empty_paragraphs: usize,
}
/// Document metadata summary.
#[derive(Debug, Serialize)]
pub struct MetadataInfo {
/// Document title (empty string if not set).
pub title: String,
/// Document author (empty string if not set).
pub author: String,
/// Document subject (empty string if not set).
#[serde(skip_serializing_if = "String::is_empty")]
pub subject: String,
/// Creation date in ISO 8601 format.
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<String>,
/// Last modification date in ISO 8601 format.
#[serde(skip_serializing_if = "Option::is_none")]
pub modified: Option<String>,
/// Searchable keywords.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub keywords: Vec<String>,
}
/// Output data from a successful inspection.
#[derive(Debug, Serialize)]
pub struct InspectData {
/// Document metadata (title, author, dates, etc.).
pub metadata: MetadataInfo,
/// Total number of sections.
pub sections: usize,
/// Total number of paragraphs across all sections.
pub total_paragraphs: usize,
/// Total number of tables.
pub total_tables: usize,
/// Total number of images.
pub total_images: usize,
/// Total number of charts.
pub total_charts: usize,
/// Per-section detail.
pub section_details: Vec<SectionDetail>,
/// Decoder warnings raised while decoding (`ops::InspectOutput::warnings`).
/// Omitted when empty.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<ToolWarningInfo>,
}
/// Inspect an HWPX file and return structural summary.
///
/// `_show_styles` stays unused: `InspectData` has no `styles` field (schema
/// freeze — adding one is a W4 decision), matching the pre-migration tool,
/// which also decoded without ever building a style summary.
///
/// The legacy contract counts `tables`/`images`/`charts` (and `paragraphs`)
/// **top-level only** — `Section::content_counts()`/`paragraphs.len()`, not
/// descending into table cells, headers/footers, notes, memos or master
/// pages. `ops::inspect`'s per-section deep counts
/// (`InspectSection::{tables,images,charts,paragraphs}`) do that deeper
/// traversal, so this maps from the shallow counterparts
/// (`InspectSection::top_level_*`) instead — values are byte-identical to
/// what this file computed by hand before. Decoder warnings
/// (`ops::InspectOutput::warnings`) are surfaced through
/// `InspectData::warnings`.
pub fn run_inspect(file_path: &str, _show_styles: bool) -> Result<InspectData, ToolErrorInfo> {
let bytes = read_file_bytes(file_path)?;
let out = ops::inspect(&bytes, &InspectOptions::default())
.map_err(|e| compat::tool_error(Tool::Inspect, e))?;
let warnings: Vec<ToolWarningInfo> = out.warnings.iter().map(compat::warning).collect();
let report = out.report;
let metadata = MetadataInfo {
title: report.metadata.title,
author: report.metadata.author,
subject: report.metadata.subject,
created: report.metadata.created,
modified: report.metadata.modified,
keywords: report.metadata.keywords,
};
let mut total_tables: usize = 0;
let mut total_images: usize = 0;
let mut total_charts: usize = 0;
let mut total_paragraphs: usize = 0;
let section_details: Vec<SectionDetail> = report
.section_details
.into_iter()
.map(|s| {
total_tables += s.top_level_tables;
total_images += s.top_level_images;
total_charts += s.top_level_charts;
total_paragraphs += s.top_level_paragraphs;
SectionDetail {
index: s.index,
paragraphs: s.top_level_paragraphs,
tables: s.top_level_tables,
images: s.top_level_images,
charts: s.top_level_charts,
has_header: s.has_header,
has_footer: s.has_footer,
has_page_number: s.has_page_number,
all_tables: s.all_tables,
all_images: s.all_images,
all_text_boxes: s.all_text_boxes,
all_lines: s.all_lines,
all_rectangles: s.all_rectangles,
all_polygons: s.all_polygons,
top_level_non_empty_paragraphs: s.top_level_non_empty_paragraphs,
deep_paragraphs: s.deep_paragraphs,
deep_non_empty_paragraphs: s.deep_non_empty_paragraphs,
}
})
.collect();
Ok(InspectData {
metadata,
sections: report.sections,
total_paragraphs,
total_tables,
total_images,
total_charts,
section_details,
warnings,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(rel: &str) -> String {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures")
.join(rel)
.to_str()
.unwrap()
.to_string()
}
#[test]
fn inspect_via_mcp_surface_has_no_warnings_on_a_clean_document() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("probe.hwpx");
crate::tools::convert::run_convert(
"# 제목\n\n본문 문단입니다.",
false,
path.to_str().unwrap(),
"default",
)
.unwrap();
let data = run_inspect(path.to_str().unwrap(), false).unwrap();
assert_eq!(data.sections, 1);
assert!(data.warnings.is_empty(), "a clean document must not warn: {:?}", data.warnings);
}
/// 줄 조판 캐시가 낡은 fixture 를 inspect 하면, 디코드 경고
/// (`LAYOUT_CACHE_DROPPED`)가 `warnings` 에 실려야 한다.
#[test]
fn inspect_surfaces_decode_warnings() {
let path = fixture("layout/stale-line-cache.hwpx");
let data = run_inspect(&path, false).unwrap();
assert!(
data.warnings.iter().any(|w| w.code == "LAYOUT_CACHE_DROPPED"),
"inspect must surface the decode warning: {:?}",
data.warnings
);
let value = serde_json::to_value(&data).unwrap();
assert_eq!(value["warnings"][0]["code"], "LAYOUT_CACHE_DROPPED");
assert!(!value["warnings"][0]["message"].as_str().unwrap_or_default().is_empty());
}
#[test]
fn inspect_missing_file_reports_file_not_found() {
let err = run_inspect("/nonexistent/inspect-warnings-probe.hwpx", false).unwrap_err();
assert_eq!(err.code, "FILE_NOT_FOUND");
}
/// `SectionDetail` must carry `ops::InspectSection`'s nine `all_`/`deep_`
/// counts, not just the eight top-level ones. Checked against a *direct*
/// `ops::inspect` call on the same bytes rather than hardcoded numbers,
/// so the assertions track whatever the decoder actually reports instead
/// of a value copied out of a one-off run.
#[test]
fn inspect_section_detail_carries_ops_package_scope_and_deep_counts() {
let path = fixture("mixed/mixed_01_image_and_chart_same_doc.hwpx");
let bytes = std::fs::read(&path).unwrap();
let ops_out = ops::inspect(&bytes, &InspectOptions::default()).unwrap();
let ops_section =
ops_out.report.section_details.first().expect("fixture has at least one section");
let data = run_inspect(&path, false).unwrap();
let detail = data.section_details.first().expect("mcp inspect must report the section too");
// Existing eight fields: unchanged mapping (top-level scope).
assert_eq!(detail.index, ops_section.index);
assert_eq!(detail.paragraphs, ops_section.top_level_paragraphs);
assert_eq!(detail.tables, ops_section.top_level_tables);
assert_eq!(detail.images, ops_section.top_level_images);
assert_eq!(detail.charts, ops_section.top_level_charts);
assert_eq!(detail.has_header, ops_section.has_header);
assert_eq!(detail.has_footer, ops_section.has_footer);
assert_eq!(detail.has_page_number, ops_section.has_page_number);
// New nine fields: same name, same value, straight from ops.
assert_eq!(detail.all_tables, ops_section.all_tables);
assert_eq!(detail.all_images, ops_section.all_images);
assert_eq!(detail.all_text_boxes, ops_section.all_text_boxes);
assert_eq!(detail.all_lines, ops_section.all_lines);
assert_eq!(detail.all_rectangles, ops_section.all_rectangles);
assert_eq!(detail.all_polygons, ops_section.all_polygons);
assert_eq!(
detail.top_level_non_empty_paragraphs,
ops_section.top_level_non_empty_paragraphs
);
assert_eq!(detail.deep_paragraphs, ops_section.deep_paragraphs);
assert_eq!(detail.deep_non_empty_paragraphs, ops_section.deep_non_empty_paragraphs);
// At least one of the newly-exposed counts must be non-zero on this
// fixture, or the equality assertions above would pass vacuously
// (both sides zero) without ever exercising a real decoded count.
assert!(
ops_section.all_images > 0 || ops_section.deep_paragraphs > 0,
"fixture must exercise at least one non-trivial package-scope/deep count: {ops_section:?}"
);
}
/// The nine `all_`/`deep_` fields must land *after* the eight top-level
/// ones — additive, not reordered — because a client that destructures
/// `section_details[i]` positionally (a naive JSON-schema consumer, a
/// Python `TypedDict` that iterates `.items()`) would otherwise silently
/// pick up the wrong value for an older field.
#[test]
fn inspect_section_detail_key_order_is_existing_eight_then_new_nine() {
let path = fixture("mixed/mixed_01_image_and_chart_same_doc.hwpx");
let data = run_inspect(&path, false).unwrap();
let detail = data.section_details.first().expect("fixture has at least one section");
let json = serde_json::to_string(detail).unwrap();
let expected_order = [
"index",
"paragraphs",
"tables",
"images",
"charts",
"has_header",
"has_footer",
"has_page_number",
"all_tables",
"all_images",
"all_text_boxes",
"all_lines",
"all_rectangles",
"all_polygons",
"top_level_non_empty_paragraphs",
"deep_paragraphs",
"deep_non_empty_paragraphs",
];
let mut last_pos = 0;
for key in expected_order {
let needle = format!("\"{key}\":");
let pos = json
.find(&needle)
.unwrap_or_else(|| panic!("key {key} missing from serialized section: {json}"));
assert!(pos >= last_pos, "key {key} out of order in serialized section: {json}");
last_pos = pos;
}
// Exactly this key set — no stray extras, nothing missing — checked
// independently of order (serde_json's `Value::Object` may not
// preserve insertion order without the `preserve_order` feature, so
// this only asserts the *set*; the loop above already asserts order
// straight from the serialized struct).
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
let keys: std::collections::BTreeSet<&str> =
value.as_object().unwrap().keys().map(String::as_str).collect();
let expected_set: std::collections::BTreeSet<&str> = expected_order.into_iter().collect();
assert_eq!(keys, expected_set);
}
}