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
// SPDX-License-Identifier: MIT
//! Independently observed HN-A outline records; see docs/research/hnc8-outline-fields.md.
use super::{Hnc8Reader, Location, Variant, read_fixed};
use crate::{Bookmark, BookmarkVisitor, Cancellation, RangedSource, Result, gb18030};
use std::fmt;
/// Defects whose location [`OutlineReport`] retains; later ones are only counted.
pub const MAX_RECORDED_OUTLINE_DEFECTS: usize = 16;
/// How a defective HN-A outline entry was handled.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum OutlineRepair {
/// The entry was not written. Its descendants are re-parented to the
/// nearest written ancestor instead of being dropped.
#[default]
Skipped,
/// The entry was written at a shallower level.
Clamped,
}
/// One defective HN-A outline entry, located by an absolute source offset.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct OutlineDefect {
/// Absolute byte offset of the defective field in the source.
pub offset: u64,
pub reason: &'static str,
pub repair: OutlineRepair,
}
impl fmt::Display for OutlineDefect {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let action = match self.repair {
OutlineRepair::Skipped => "skipped",
OutlineRepair::Clamped => "re-parented",
};
write!(
f,
"{action} HN-A bookmark at byte {}: {}",
self.offset, self.reason
)
}
}
/// Bounded result of an HN-A outline traversal.
///
/// A malformed entry is a bookmark defect, not a document defect: it is
/// skipped or clamped and counted here. Errors that make the whole outline
/// table unreadable still fail the traversal.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct OutlineReport {
/// Records declared by the outline table.
pub declared: u32,
/// Entries passed to the visitor.
pub written: u32,
/// Defective entries, including those beyond the recorded locations.
pub defects: u32,
/// Bookmarks were requested for a C8 or HN-B source, whose outline layout
/// is unverified, so none were written. This is not a confirmed empty TOC.
pub unverified: bool,
recorded: [OutlineDefect; MAX_RECORDED_OUTLINE_DEFECTS],
}
impl OutlineReport {
/// The first [`MAX_RECORDED_OUTLINE_DEFECTS`] defects in source order.
pub fn recorded_defects(&self) -> &[OutlineDefect] {
&self.recorded[..(self.defects as usize).min(MAX_RECORDED_OUTLINE_DEFECTS)]
}
fn record(&mut self, defect: OutlineDefect) {
if let Some(slot) = self.recorded.get_mut(self.defects as usize) {
*slot = defect;
}
// Bounded by the declared record count, itself at most `u32::MAX`.
self.defects += 1;
}
}
impl<S: RangedSource, C: Cancellation> Hnc8Reader<'_, S, C> {
/// Declared outline record count for the validated HN-A container layout.
/// Titles, levels and destinations still need `visit_bookmarks` validation.
/// C8/HN-B return `None`: their outline absence must not be inferred.
pub fn declared_bookmark_count(&self) -> Option<u32> {
(self.header.variant == Variant::HnA)
.then(|| ((self.header.page_index.offset - 0x15c) / 308) as u32)
}
/// Visit HN-A bookmarks one record at a time, calling the visitor for each.
///
/// `map_page` maps a one-based physical source page to a zero-based emitted
/// page. `None` marks an omitted destination; no neighboring-page fallback
/// is applied. Returned indexes must be below `output_pages`. The caller
/// owns any mapping storage. This does not infer C8/HN-B outline absence.
///
/// Depth is one-based in the source and zero-based in `Bookmark`; use 64
/// for the initial validated profile. An entry with an invalid title,
/// page, destination or zero level is skipped; a level deeper than its
/// written parent allows, or than `max_depth`, is clamped. Both are
/// counted in the returned report. Read errors, limits and cancellation
/// still fail. After a visitor failure the caller must discard the
/// partial output.
pub fn visit_bookmarks<V: BookmarkVisitor, F: FnMut(u32) -> Option<u32>>(
&mut self,
max_depth: u32,
output_pages: u32,
map_page: F,
visitor: &mut V,
) -> Result<OutlineReport> {
let loc = Location {
variant: Some(self.header.variant),
offset: 0x158,
page: None,
image: None,
};
self.bookmarks(max_depth, output_pages, map_page, visitor, loc)
}
fn bookmarks<V: BookmarkVisitor, F: FnMut(u32) -> Option<u32>>(
&mut self,
max_depth: u32,
output_pages: u32,
mut map_page: F,
visitor: &mut V,
loc: Location,
) -> Result<OutlineReport> {
if self.cancellation.is_cancelled() {
return Err(loc.cancelled());
}
let Some(count) = self.declared_bookmark_count() else {
return Err(loc.unsupported("outline variant"));
};
if max_depth == 0 {
return Err(loc.malformed("outline depth: depth limit must be positive"));
}
if output_pages > self.limits.max_pages {
return Err(loc.limit(
"outline output pages",
u64::from(self.limits.max_pages),
u64::from(output_pages),
));
}
// HN-A open already validated the record interval and format-specific count.
if count > self.limits.max_bookmarks {
return Err(loc.limit(
"bookmarks",
u64::from(self.limits.max_bookmarks),
u64::from(count),
));
}
let mut report = OutlineReport {
declared: count,
..OutlineReport::default()
};
// The last written level bounds the next one. The previous source
// level tells a source level skip (a defect) from a child re-parented
// below a skipped or clamped ancestor (not a further defect). A zero
// level hides its children's intended parent, so it becomes unknown.
let mut written_level = 0_u32;
let mut source_level = Some(0_u64);
for ordinal in 0..count {
let offset = 0x15c + u64::from(ordinal) * 308;
let at = loc.at(offset);
let mut record = [0_u8; 308];
read_fixed(
self.source,
self.limits,
self.cancellation,
offset,
&mut record,
at,
"outline record",
)?;
let level = u32::from_le_bytes(record[304..308].try_into().expect("fixed field width"));
let entry = 'entry: {
let Some(title_end) = record[..256].iter().position(|&byte| byte == 0) else {
break 'entry Err((offset, "title has no NUL terminator"));
};
// A conservative UTF-8 capacity bound for this fixed-width title.
let allocation = title_end as u64 * 4;
if allocation > self.limits.max_allocation_bytes {
return Err(at.limit(
"outline title bytes",
self.limits.max_allocation_bytes,
allocation,
));
}
let title = match gb18030::decode(&record[..title_end]) {
Ok(title) => title,
Err(at) => {
break 'entry Err((offset + at as u64, "title is not valid GB18030"));
}
};
let page_at = offset + 280;
let field = &record[280..292];
let Some(end) = field.iter().position(|&byte| byte == 0) else {
break 'entry Err((page_at, "page has no NUL terminator"));
};
if end == 0 || !field[..end].iter().all(u8::is_ascii_digit) {
break 'entry Err((page_at, "page is not a decimal number"));
}
// At most eleven digits fit before NUL, so accumulation fits u64.
let page = field[..end]
.iter()
.fold(0_u64, |value, byte| value * 10 + u64::from(byte - b'0'));
if page == 0 || page > u64::from(self.header.page_count) {
break 'entry Err((page_at, "destination is outside source pages"));
}
let Some(page_index) = map_page(page as u32).filter(|&index| index < output_pages)
else {
break 'entry Err((page_at, "destination page was not emitted"));
};
if level == 0 {
break 'entry Err((offset + 304, "level is zero"));
}
Ok((title, page_index))
};
let source_parent =
std::mem::replace(&mut source_level, (level != 0).then_some(u64::from(level)));
let (title, page_index) = match entry {
Ok(entry) => entry,
Err((offset, reason)) => {
report.record(OutlineDefect {
offset,
reason,
repair: OutlineRepair::Skipped,
});
continue;
}
};
let written = level.min(max_depth).min(written_level.saturating_add(1));
let reason = if level > max_depth {
Some("level exceeds the configured depth")
} else if source_parent.is_some_and(|parent| u64::from(level) > parent + 1) {
Some("level skips a parent")
} else {
None
};
if let Some(reason) = reason.filter(|_| written < level) {
report.record(OutlineDefect {
offset: offset + 304,
reason,
repair: OutlineRepair::Clamped,
});
}
visitor
.visit(Bookmark {
title,
depth: written - 1,
page_index,
})
.map_err(|source| at.locate(source))?;
report.written += 1;
written_level = written;
}
Ok(report)
}
}
#[cfg(test)]
mod tests;