msdoc2docx 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- doc2docx/__init__.py +6 -0
- doc2docx/__main__.py +4 -0
- doc2docx/binary/__init__.py +4 -0
- doc2docx/binary/reader.py +87 -0
- doc2docx/cfb/__init__.py +5 -0
- doc2docx/cfb/constants.py +9 -0
- doc2docx/cfb/container.py +370 -0
- doc2docx/cfb/directory.py +135 -0
- doc2docx/cfb/header.py +108 -0
- doc2docx/cli.py +100 -0
- doc2docx/converter.py +321 -0
- doc2docx/diagnostics.py +105 -0
- doc2docx/errors.py +37 -0
- doc2docx/model/__init__.py +57 -0
- doc2docx/model/document.py +806 -0
- doc2docx/msdoc/__init__.py +24 -0
- doc2docx/msdoc/document_properties.py +31 -0
- doc2docx/msdoc/fib.py +246 -0
- doc2docx/msdoc/fonts.py +132 -0
- doc2docx/msdoc/formatting.py +467 -0
- doc2docx/msdoc/headers.py +184 -0
- doc2docx/msdoc/pieces.py +407 -0
- doc2docx/msdoc/sections.py +259 -0
- doc2docx/msdoc/sprm.py +753 -0
- doc2docx/msdoc/styles.py +288 -0
- doc2docx/ooxml/__init__.py +3 -0
- doc2docx/ooxml/package.py +1163 -0
- msdoc2docx-0.7.0.dist-info/METADATA +84 -0
- msdoc2docx-0.7.0.dist-info/RECORD +33 -0
- msdoc2docx-0.7.0.dist-info/WHEEL +5 -0
- msdoc2docx-0.7.0.dist-info/entry_points.txt +2 -0
- msdoc2docx-0.7.0.dist-info/licenses/LICENSE +21 -0
- msdoc2docx-0.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""CHPX/PAPX FKP parsing and physical-FC to logical-CP mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from bisect import bisect_right
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import struct
|
|
8
|
+
|
|
9
|
+
from ..diagnostics import ConversionReport
|
|
10
|
+
from ..errors import InvalidWordDocument
|
|
11
|
+
from ..model import CharacterProperties, FontDefinition, ParagraphProperties, StyleSheet
|
|
12
|
+
from .pieces import PieceTable
|
|
13
|
+
from .sprm import (
|
|
14
|
+
apply_character_modifiers,
|
|
15
|
+
apply_paragraph_modifiers,
|
|
16
|
+
parse_grpprl,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
FKP_SIZE = 512
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True, frozen=True)
|
|
24
|
+
class CharacterFormatSpan:
|
|
25
|
+
cp_start: int
|
|
26
|
+
cp_end: int
|
|
27
|
+
properties: CharacterProperties
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True, frozen=True)
|
|
31
|
+
class ParagraphFormatSpan:
|
|
32
|
+
cp_start: int
|
|
33
|
+
cp_end: int
|
|
34
|
+
properties: ParagraphProperties
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True, frozen=True)
|
|
38
|
+
class FormattingMap:
|
|
39
|
+
character_spans: tuple[CharacterFormatSpan, ...]
|
|
40
|
+
paragraph_spans: tuple[ParagraphFormatSpan, ...]
|
|
41
|
+
character_fkp_run_count: int = 0
|
|
42
|
+
paragraph_fkp_run_count: int = 0
|
|
43
|
+
|
|
44
|
+
def character_properties_at(self, cp: int) -> CharacterProperties:
|
|
45
|
+
if not self.character_spans:
|
|
46
|
+
return CharacterProperties()
|
|
47
|
+
index = bisect_right(
|
|
48
|
+
self.character_spans,
|
|
49
|
+
cp,
|
|
50
|
+
key=lambda span: span.cp_start,
|
|
51
|
+
) - 1
|
|
52
|
+
if index >= 0:
|
|
53
|
+
span = self.character_spans[index]
|
|
54
|
+
if cp < span.cp_end:
|
|
55
|
+
return span.properties
|
|
56
|
+
return CharacterProperties()
|
|
57
|
+
|
|
58
|
+
def paragraph_properties_at(self, cp: int) -> ParagraphProperties:
|
|
59
|
+
if not self.paragraph_spans:
|
|
60
|
+
return ParagraphProperties()
|
|
61
|
+
index = bisect_right(
|
|
62
|
+
self.paragraph_spans,
|
|
63
|
+
cp,
|
|
64
|
+
key=lambda span: span.cp_start,
|
|
65
|
+
) - 1
|
|
66
|
+
if index >= 0:
|
|
67
|
+
span = self.paragraph_spans[index]
|
|
68
|
+
if cp < span.cp_end:
|
|
69
|
+
return span.properties
|
|
70
|
+
return ParagraphProperties()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_bte_plc(
|
|
74
|
+
table_stream: bytes,
|
|
75
|
+
*,
|
|
76
|
+
offset: int,
|
|
77
|
+
size: int,
|
|
78
|
+
label: str,
|
|
79
|
+
) -> tuple[tuple[int, int, int], ...]:
|
|
80
|
+
if size == 0:
|
|
81
|
+
return ()
|
|
82
|
+
if offset < 0 or size < 0 or offset > len(table_stream) - size:
|
|
83
|
+
raise InvalidWordDocument(
|
|
84
|
+
f"{label} range [{offset}, {offset + size}) exceeds Table stream"
|
|
85
|
+
)
|
|
86
|
+
data = table_stream[offset : offset + size]
|
|
87
|
+
if len(data) < 12 or (len(data) - 4) % 8:
|
|
88
|
+
raise InvalidWordDocument(
|
|
89
|
+
f"{label} size {len(data)} does not match the 8*n+4 PLC layout"
|
|
90
|
+
)
|
|
91
|
+
count = (len(data) - 4) // 8
|
|
92
|
+
fcs = struct.unpack_from(f"<{count + 1}I", data, 0)
|
|
93
|
+
for previous, current in zip(fcs, fcs[1:]):
|
|
94
|
+
if current <= previous:
|
|
95
|
+
raise InvalidWordDocument(f"{label} FC values are not increasing")
|
|
96
|
+
page_offset = 4 * (count + 1)
|
|
97
|
+
pages = struct.unpack_from(f"<{count}I", data, page_offset)
|
|
98
|
+
return tuple((fcs[index], fcs[index + 1], pages[index]) for index in range(count))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _read_fkp_page(word_document: bytes, page_number: int, *, label: str) -> bytes:
|
|
102
|
+
offset = page_number * FKP_SIZE
|
|
103
|
+
if offset < 0 or offset > len(word_document) - FKP_SIZE:
|
|
104
|
+
raise InvalidWordDocument(
|
|
105
|
+
f"{label} page {page_number} at WordDocument offset {offset} is truncated"
|
|
106
|
+
)
|
|
107
|
+
return word_document[offset : offset + FKP_SIZE]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _run_boundaries(page: bytes, count: int, *, label: str) -> tuple[int, ...]:
|
|
111
|
+
if count == 0:
|
|
112
|
+
raise InvalidWordDocument(f"{label} contains zero runs")
|
|
113
|
+
fcs = struct.unpack_from(f"<{count + 1}I", page, 0)
|
|
114
|
+
for previous, current in zip(fcs, fcs[1:]):
|
|
115
|
+
if current <= previous:
|
|
116
|
+
raise InvalidWordDocument(f"{label} FC values are not increasing")
|
|
117
|
+
return fcs
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _merge_character_spans(
|
|
121
|
+
spans: list[CharacterFormatSpan],
|
|
122
|
+
) -> tuple[CharacterFormatSpan, ...]:
|
|
123
|
+
result: list[CharacterFormatSpan] = []
|
|
124
|
+
for span in sorted(spans, key=lambda item: (item.cp_start, item.cp_end)):
|
|
125
|
+
if span.cp_start >= span.cp_end:
|
|
126
|
+
continue
|
|
127
|
+
if (
|
|
128
|
+
result
|
|
129
|
+
and result[-1].cp_end == span.cp_start
|
|
130
|
+
and result[-1].properties == span.properties
|
|
131
|
+
):
|
|
132
|
+
previous = result[-1]
|
|
133
|
+
result[-1] = CharacterFormatSpan(
|
|
134
|
+
previous.cp_start,
|
|
135
|
+
span.cp_end,
|
|
136
|
+
span.properties,
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
result.append(span)
|
|
140
|
+
return tuple(result)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _merge_paragraph_spans(
|
|
144
|
+
spans: list[ParagraphFormatSpan],
|
|
145
|
+
) -> tuple[ParagraphFormatSpan, ...]:
|
|
146
|
+
result: list[ParagraphFormatSpan] = []
|
|
147
|
+
for span in sorted(spans, key=lambda item: (item.cp_start, item.cp_end)):
|
|
148
|
+
if span.cp_start >= span.cp_end:
|
|
149
|
+
continue
|
|
150
|
+
if (
|
|
151
|
+
result
|
|
152
|
+
and result[-1].cp_end == span.cp_start
|
|
153
|
+
and result[-1].properties == span.properties
|
|
154
|
+
):
|
|
155
|
+
previous = result[-1]
|
|
156
|
+
result[-1] = ParagraphFormatSpan(
|
|
157
|
+
previous.cp_start,
|
|
158
|
+
span.cp_end,
|
|
159
|
+
span.properties,
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
result.append(span)
|
|
163
|
+
return tuple(result)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def read_formatting(
|
|
167
|
+
table_stream: bytes,
|
|
168
|
+
word_document: bytes,
|
|
169
|
+
piece_table: PieceTable,
|
|
170
|
+
*,
|
|
171
|
+
fc_plcf_bte_chpx: int,
|
|
172
|
+
lcb_plcf_bte_chpx: int,
|
|
173
|
+
fc_plcf_bte_papx: int,
|
|
174
|
+
lcb_plcf_bte_papx: int,
|
|
175
|
+
report: ConversionReport,
|
|
176
|
+
fonts: tuple[FontDefinition, ...] = (),
|
|
177
|
+
style_sheet: StyleSheet | None = None,
|
|
178
|
+
) -> FormattingMap:
|
|
179
|
+
style_sheet = style_sheet or StyleSheet()
|
|
180
|
+
font_names = {font.index: font.name for font in fonts}
|
|
181
|
+
character_btes = _read_bte_plc(
|
|
182
|
+
table_stream,
|
|
183
|
+
offset=fc_plcf_bte_chpx,
|
|
184
|
+
size=lcb_plcf_bte_chpx,
|
|
185
|
+
label="PlcBteChpx",
|
|
186
|
+
)
|
|
187
|
+
paragraph_btes = _read_bte_plc(
|
|
188
|
+
table_stream,
|
|
189
|
+
offset=fc_plcf_bte_papx,
|
|
190
|
+
size=lcb_plcf_bte_papx,
|
|
191
|
+
label="PlcBtePapx",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
character_spans: list[CharacterFormatSpan] = []
|
|
195
|
+
paragraph_spans: list[ParagraphFormatSpan] = []
|
|
196
|
+
unsupported_character_sprms: set[int] = set()
|
|
197
|
+
unsupported_paragraph_sprms: set[int] = set()
|
|
198
|
+
style_relative_toggles = 0
|
|
199
|
+
character_run_count = 0
|
|
200
|
+
paragraph_run_count = 0
|
|
201
|
+
|
|
202
|
+
# PAPX is parsed first because style-relative CHPX toggles depend on the
|
|
203
|
+
# paragraph style active at each CP.
|
|
204
|
+
for bte_fc_start, bte_fc_end, page_number in paragraph_btes:
|
|
205
|
+
page = _read_fkp_page(word_document, page_number, label="PapxFkp")
|
|
206
|
+
run_count = page[-1]
|
|
207
|
+
header_end = 4 * (run_count + 1) + 13 * run_count
|
|
208
|
+
if run_count == 0 or header_end > FKP_SIZE - 1:
|
|
209
|
+
raise InvalidWordDocument(f"PapxFkp has invalid cpara {run_count}")
|
|
210
|
+
fcs = _run_boundaries(page, run_count, label="PapxFkp")
|
|
211
|
+
bx_offset = 4 * (run_count + 1)
|
|
212
|
+
for index in range(run_count):
|
|
213
|
+
run_fc_start = max(fcs[index], bte_fc_start)
|
|
214
|
+
run_fc_end = min(fcs[index + 1], bte_fc_end)
|
|
215
|
+
if run_fc_start >= run_fc_end:
|
|
216
|
+
continue
|
|
217
|
+
paragraph_run_count += 1
|
|
218
|
+
papx_offset = page[bx_offset + index * 13] * 2
|
|
219
|
+
properties = ParagraphProperties()
|
|
220
|
+
if papx_offset:
|
|
221
|
+
if papx_offset >= FKP_SIZE - 1:
|
|
222
|
+
raise InvalidWordDocument("Papx offset points outside PapxFkp")
|
|
223
|
+
byte_count = page[papx_offset]
|
|
224
|
+
if byte_count:
|
|
225
|
+
content_start = papx_offset + 1
|
|
226
|
+
content_length = byte_count * 2 - 1
|
|
227
|
+
else:
|
|
228
|
+
if papx_offset + 1 >= FKP_SIZE - 1:
|
|
229
|
+
raise InvalidWordDocument("extended PapxInFkp is truncated")
|
|
230
|
+
content_start = papx_offset + 2
|
|
231
|
+
content_length = page[papx_offset + 1] * 2
|
|
232
|
+
content_end = content_start + content_length
|
|
233
|
+
if content_length < 2 or content_end > FKP_SIZE - 1:
|
|
234
|
+
raise InvalidWordDocument("PapxInFkp content exceeds PapxFkp")
|
|
235
|
+
style_id = struct.unpack_from("<H", page, content_start)[0]
|
|
236
|
+
modifiers = parse_grpprl(
|
|
237
|
+
page[content_start + 2 : content_end],
|
|
238
|
+
label="PapxInFkp.grpprl",
|
|
239
|
+
allow_trailing_zero_padding=True,
|
|
240
|
+
)
|
|
241
|
+
properties, unsupported = apply_paragraph_modifiers(
|
|
242
|
+
modifiers,
|
|
243
|
+
style_id=style_id,
|
|
244
|
+
)
|
|
245
|
+
unsupported_paragraph_sprms.update(unsupported)
|
|
246
|
+
for cp_start, cp_end in piece_table.fc_range_to_cp_ranges(
|
|
247
|
+
run_fc_start,
|
|
248
|
+
run_fc_end,
|
|
249
|
+
):
|
|
250
|
+
paragraph_spans.append(
|
|
251
|
+
ParagraphFormatSpan(cp_start, cp_end, properties)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def paragraph_at(cp: int) -> ParagraphProperties:
|
|
255
|
+
for span in paragraph_spans:
|
|
256
|
+
if span.cp_start <= cp < span.cp_end:
|
|
257
|
+
return span.properties
|
|
258
|
+
return ParagraphProperties()
|
|
259
|
+
|
|
260
|
+
# Piece Prm paragraph modifiers are applied after the PAPX grpprl.
|
|
261
|
+
paragraph_boundaries = {
|
|
262
|
+
boundary
|
|
263
|
+
for piece in piece_table.pieces
|
|
264
|
+
for boundary in (piece.cp_start, piece.cp_end)
|
|
265
|
+
}
|
|
266
|
+
paragraph_boundaries.update(
|
|
267
|
+
boundary
|
|
268
|
+
for span in paragraph_spans
|
|
269
|
+
for boundary in (span.cp_start, span.cp_end)
|
|
270
|
+
)
|
|
271
|
+
composed_paragraph_spans: list[ParagraphFormatSpan] = []
|
|
272
|
+
sorted_paragraph_boundaries = sorted(paragraph_boundaries)
|
|
273
|
+
for cp_start, cp_end in zip(
|
|
274
|
+
sorted_paragraph_boundaries,
|
|
275
|
+
sorted_paragraph_boundaries[1:],
|
|
276
|
+
):
|
|
277
|
+
if cp_start >= cp_end:
|
|
278
|
+
continue
|
|
279
|
+
properties = paragraph_at(cp_start)
|
|
280
|
+
piece = next(
|
|
281
|
+
(
|
|
282
|
+
value
|
|
283
|
+
for value in piece_table.pieces
|
|
284
|
+
if value.cp_start <= cp_start < value.cp_end
|
|
285
|
+
),
|
|
286
|
+
None,
|
|
287
|
+
)
|
|
288
|
+
if piece is not None:
|
|
289
|
+
modifiers = piece_table.modifiers_for_piece(piece, property_group=1)
|
|
290
|
+
if modifiers:
|
|
291
|
+
properties, unsupported = apply_paragraph_modifiers(
|
|
292
|
+
modifiers,
|
|
293
|
+
style_id=properties.style_id,
|
|
294
|
+
initial_properties=properties,
|
|
295
|
+
)
|
|
296
|
+
unsupported_paragraph_sprms.update(unsupported)
|
|
297
|
+
composed_paragraph_spans.append(
|
|
298
|
+
ParagraphFormatSpan(cp_start, cp_end, properties)
|
|
299
|
+
)
|
|
300
|
+
paragraph_spans = list(_merge_paragraph_spans(composed_paragraph_spans))
|
|
301
|
+
|
|
302
|
+
def paragraph_at(cp: int) -> ParagraphProperties:
|
|
303
|
+
for span in paragraph_spans:
|
|
304
|
+
if span.cp_start <= cp < span.cp_end:
|
|
305
|
+
return span.properties
|
|
306
|
+
return ParagraphProperties()
|
|
307
|
+
|
|
308
|
+
def paragraph_style_character_at(cp: int) -> CharacterProperties:
|
|
309
|
+
return style_sheet.effective_character_at(paragraph_at(cp).style_id)
|
|
310
|
+
|
|
311
|
+
paragraph_style_boundaries = {
|
|
312
|
+
boundary
|
|
313
|
+
for span in paragraph_spans
|
|
314
|
+
for boundary in (span.cp_start, span.cp_end)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for bte_fc_start, bte_fc_end, page_number in character_btes:
|
|
318
|
+
page = _read_fkp_page(word_document, page_number, label="ChpxFkp")
|
|
319
|
+
run_count = page[-1]
|
|
320
|
+
if run_count > 0x65:
|
|
321
|
+
raise InvalidWordDocument(f"ChpxFkp has invalid crun {run_count}")
|
|
322
|
+
header_end = 4 * (run_count + 1) + run_count
|
|
323
|
+
if header_end > FKP_SIZE - 1:
|
|
324
|
+
raise InvalidWordDocument("ChpxFkp header exceeds its 512-byte page")
|
|
325
|
+
fcs = _run_boundaries(page, run_count, label="ChpxFkp")
|
|
326
|
+
rgb_offset = 4 * (run_count + 1)
|
|
327
|
+
for index in range(run_count):
|
|
328
|
+
run_fc_start = max(fcs[index], bte_fc_start)
|
|
329
|
+
run_fc_end = min(fcs[index + 1], bte_fc_end)
|
|
330
|
+
if run_fc_start >= run_fc_end:
|
|
331
|
+
continue
|
|
332
|
+
character_run_count += 1
|
|
333
|
+
chpx_offset = page[rgb_offset + index] * 2
|
|
334
|
+
modifiers = ()
|
|
335
|
+
if chpx_offset:
|
|
336
|
+
if chpx_offset >= FKP_SIZE - 1:
|
|
337
|
+
raise InvalidWordDocument("Chpx offset points outside ChpxFkp")
|
|
338
|
+
byte_count = page[chpx_offset]
|
|
339
|
+
grpprl_end = chpx_offset + 1 + byte_count
|
|
340
|
+
if grpprl_end > FKP_SIZE - 1:
|
|
341
|
+
raise InvalidWordDocument("Chpx grpprl exceeds ChpxFkp")
|
|
342
|
+
modifiers = parse_grpprl(
|
|
343
|
+
page[chpx_offset + 1 : grpprl_end],
|
|
344
|
+
label="Chpx.grpprl",
|
|
345
|
+
)
|
|
346
|
+
for cp_start, cp_end in piece_table.fc_range_to_cp_ranges(
|
|
347
|
+
run_fc_start,
|
|
348
|
+
run_fc_end,
|
|
349
|
+
):
|
|
350
|
+
boundaries = [cp_start]
|
|
351
|
+
boundaries.extend(
|
|
352
|
+
boundary
|
|
353
|
+
for boundary in paragraph_style_boundaries
|
|
354
|
+
if cp_start < boundary < cp_end
|
|
355
|
+
)
|
|
356
|
+
boundaries.append(cp_end)
|
|
357
|
+
for span_start, span_end in zip(boundaries, boundaries[1:]):
|
|
358
|
+
properties, unsupported, relative_count = (
|
|
359
|
+
apply_character_modifiers(
|
|
360
|
+
modifiers,
|
|
361
|
+
base_properties=paragraph_style_character_at(span_start),
|
|
362
|
+
font_names=font_names,
|
|
363
|
+
style_properties_at=style_sheet.effective_character_at,
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
unsupported_character_sprms.update(unsupported)
|
|
367
|
+
style_relative_toggles += relative_count
|
|
368
|
+
character_spans.append(
|
|
369
|
+
CharacterFormatSpan(span_start, span_end, properties)
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def character_at(cp: int) -> CharacterProperties:
|
|
373
|
+
for span in character_spans:
|
|
374
|
+
if span.cp_start <= cp < span.cp_end:
|
|
375
|
+
return span.properties
|
|
376
|
+
return CharacterProperties()
|
|
377
|
+
|
|
378
|
+
# Piece Prm character modifiers are applied after the CHPX grpprl.
|
|
379
|
+
character_boundaries = {
|
|
380
|
+
boundary
|
|
381
|
+
for piece in piece_table.pieces
|
|
382
|
+
for boundary in (piece.cp_start, piece.cp_end)
|
|
383
|
+
}
|
|
384
|
+
character_boundaries.update(
|
|
385
|
+
boundary
|
|
386
|
+
for span in character_spans
|
|
387
|
+
for boundary in (span.cp_start, span.cp_end)
|
|
388
|
+
)
|
|
389
|
+
character_boundaries.update(paragraph_style_boundaries)
|
|
390
|
+
composed_character_spans: list[CharacterFormatSpan] = []
|
|
391
|
+
sorted_character_boundaries = sorted(character_boundaries)
|
|
392
|
+
for cp_start, cp_end in zip(
|
|
393
|
+
sorted_character_boundaries,
|
|
394
|
+
sorted_character_boundaries[1:],
|
|
395
|
+
):
|
|
396
|
+
if cp_start >= cp_end:
|
|
397
|
+
continue
|
|
398
|
+
properties = character_at(cp_start)
|
|
399
|
+
piece = next(
|
|
400
|
+
(
|
|
401
|
+
value
|
|
402
|
+
for value in piece_table.pieces
|
|
403
|
+
if value.cp_start <= cp_start < value.cp_end
|
|
404
|
+
),
|
|
405
|
+
None,
|
|
406
|
+
)
|
|
407
|
+
if piece is not None:
|
|
408
|
+
modifiers = piece_table.modifiers_for_piece(piece, property_group=2)
|
|
409
|
+
if modifiers:
|
|
410
|
+
properties, unsupported, relative_count = apply_character_modifiers(
|
|
411
|
+
modifiers,
|
|
412
|
+
initial_properties=properties,
|
|
413
|
+
base_properties=paragraph_style_character_at(cp_start),
|
|
414
|
+
font_names=font_names,
|
|
415
|
+
style_properties_at=style_sheet.effective_character_at,
|
|
416
|
+
)
|
|
417
|
+
unsupported_character_sprms.update(unsupported)
|
|
418
|
+
style_relative_toggles += relative_count
|
|
419
|
+
composed_character_spans.append(
|
|
420
|
+
CharacterFormatSpan(cp_start, cp_end, properties)
|
|
421
|
+
)
|
|
422
|
+
character_spans = list(_merge_character_spans(composed_character_spans))
|
|
423
|
+
|
|
424
|
+
if unsupported_character_sprms:
|
|
425
|
+
report.warning(
|
|
426
|
+
"UNSUPPORTED_CHARACTER_SPRMS",
|
|
427
|
+
"some direct character properties are not yet supported",
|
|
428
|
+
opcodes=[f"0x{value:04X}" for value in sorted(unsupported_character_sprms)],
|
|
429
|
+
)
|
|
430
|
+
if unsupported_paragraph_sprms:
|
|
431
|
+
report.warning(
|
|
432
|
+
"UNSUPPORTED_PARAGRAPH_SPRMS",
|
|
433
|
+
"some direct paragraph properties are not yet supported",
|
|
434
|
+
opcodes=[f"0x{value:04X}" for value in sorted(unsupported_paragraph_sprms)],
|
|
435
|
+
)
|
|
436
|
+
referenced_style_ids = {
|
|
437
|
+
properties.style_id
|
|
438
|
+
for properties in (
|
|
439
|
+
[span.properties for span in paragraph_spans]
|
|
440
|
+
+ [span.properties for span in character_spans]
|
|
441
|
+
)
|
|
442
|
+
if properties.style_id is not None
|
|
443
|
+
}
|
|
444
|
+
missing_style_ids = sorted(
|
|
445
|
+
style_id
|
|
446
|
+
for style_id in referenced_style_ids
|
|
447
|
+
if style_id != 0 and style_sheet.style_at(style_id) is None
|
|
448
|
+
)
|
|
449
|
+
if missing_style_ids:
|
|
450
|
+
report.warning(
|
|
451
|
+
"MISSING_REFERENCED_STYLES",
|
|
452
|
+
"some formatting runs reference absent or unsupported styles",
|
|
453
|
+
style_ids=missing_style_ids,
|
|
454
|
+
)
|
|
455
|
+
if style_relative_toggles:
|
|
456
|
+
report.info(
|
|
457
|
+
"STYLE_RELATIVE_TOGGLES_RESOLVED",
|
|
458
|
+
"style-relative character toggles were resolved through STSH inheritance",
|
|
459
|
+
count=style_relative_toggles,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
return FormattingMap(
|
|
463
|
+
tuple(character_spans),
|
|
464
|
+
tuple(paragraph_spans),
|
|
465
|
+
character_run_count,
|
|
466
|
+
paragraph_run_count,
|
|
467
|
+
)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Header/footer story extraction and section association from PlcfHdd."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
import struct
|
|
8
|
+
|
|
9
|
+
from ..diagnostics import ConversionReport, SourceLocation
|
|
10
|
+
from ..errors import InvalidWordDocument
|
|
11
|
+
from ..model import (
|
|
12
|
+
CharacterProperties,
|
|
13
|
+
HeaderFooterStory,
|
|
14
|
+
ParagraphProperties,
|
|
15
|
+
SectionProperties,
|
|
16
|
+
StoryCharacter,
|
|
17
|
+
parse_main_story,
|
|
18
|
+
)
|
|
19
|
+
from .pieces import PieceTable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_STORIES_PER_SECTION = (
|
|
23
|
+
("even_header", "even header"),
|
|
24
|
+
("default_header", "default header"),
|
|
25
|
+
("even_footer", "even footer"),
|
|
26
|
+
("default_footer", "default footer"),
|
|
27
|
+
("first_header", "first-page header"),
|
|
28
|
+
("first_footer", "first-page footer"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True, frozen=True)
|
|
33
|
+
class HeaderFooterCollection:
|
|
34
|
+
sections: tuple[SectionProperties, ...]
|
|
35
|
+
story_count: int = 0
|
|
36
|
+
paragraph_count: int = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_story_cps(
|
|
40
|
+
table_stream: bytes,
|
|
41
|
+
*,
|
|
42
|
+
offset: int,
|
|
43
|
+
size: int,
|
|
44
|
+
ccp_headers: int,
|
|
45
|
+
section_count: int,
|
|
46
|
+
) -> tuple[int, ...]:
|
|
47
|
+
if offset < 0 or size < 0 or offset > len(table_stream) - size:
|
|
48
|
+
raise InvalidWordDocument(
|
|
49
|
+
f"PlcfHdd range [{offset}, {offset + size}) exceeds Table stream"
|
|
50
|
+
)
|
|
51
|
+
story_count = 6 + 6 * section_count
|
|
52
|
+
expected_cp_count = story_count + 2
|
|
53
|
+
expected_size = expected_cp_count * 4
|
|
54
|
+
if size != expected_size:
|
|
55
|
+
raise InvalidWordDocument(
|
|
56
|
+
f"PlcfHdd size {size} does not contain {expected_cp_count} CP values "
|
|
57
|
+
f"for {section_count} section(s)"
|
|
58
|
+
)
|
|
59
|
+
cps = struct.unpack_from(f"<{expected_cp_count}I", table_stream, offset)
|
|
60
|
+
story_cps = cps[:-1] # The final PlcfHdd CP is undefined by MS-DOC.
|
|
61
|
+
if not story_cps or story_cps[0] != 0:
|
|
62
|
+
raise InvalidWordDocument("PlcfHdd does not begin at header-story CP 0")
|
|
63
|
+
for previous, current in zip(story_cps, story_cps[1:]):
|
|
64
|
+
if current < previous:
|
|
65
|
+
raise InvalidWordDocument("PlcfHdd story CP values are decreasing")
|
|
66
|
+
if ccp_headers < 1 or story_cps[-1] != ccp_headers - 1:
|
|
67
|
+
raise InvalidWordDocument(
|
|
68
|
+
"PlcfHdd second-to-last CP does not equal ccpHdd minus one"
|
|
69
|
+
)
|
|
70
|
+
if any(cp >= ccp_headers for cp in story_cps):
|
|
71
|
+
raise InvalidWordDocument("PlcfHdd story CP points beyond the header document")
|
|
72
|
+
return story_cps
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_header_footer_stories(
|
|
76
|
+
table_stream: bytes,
|
|
77
|
+
piece_table: PieceTable,
|
|
78
|
+
sections: Sequence[SectionProperties],
|
|
79
|
+
*,
|
|
80
|
+
offset: int,
|
|
81
|
+
size: int,
|
|
82
|
+
ccp_headers: int,
|
|
83
|
+
header_story_cp_start: int,
|
|
84
|
+
report: ConversionReport,
|
|
85
|
+
character_properties_at: Callable[[int], CharacterProperties] | None = None,
|
|
86
|
+
paragraph_properties_at: Callable[[int], ParagraphProperties] | None = None,
|
|
87
|
+
) -> HeaderFooterCollection:
|
|
88
|
+
"""Read non-empty header/footer stories and attach them to their sections."""
|
|
89
|
+
|
|
90
|
+
section_values = tuple(sections)
|
|
91
|
+
if ccp_headers == 0 and size == 0:
|
|
92
|
+
return HeaderFooterCollection(section_values)
|
|
93
|
+
if ccp_headers == 0 or size == 0:
|
|
94
|
+
raise InvalidWordDocument(
|
|
95
|
+
"header document and PlcfHdd must either both exist or both be absent"
|
|
96
|
+
)
|
|
97
|
+
header_story_cp_end = header_story_cp_start + ccp_headers
|
|
98
|
+
if header_story_cp_end > piece_table.cp_end:
|
|
99
|
+
raise InvalidWordDocument(
|
|
100
|
+
f"header story range [{header_story_cp_start}, {header_story_cp_end}) "
|
|
101
|
+
f"exceeds Piece Table CP {piece_table.cp_end}"
|
|
102
|
+
)
|
|
103
|
+
story_cps = _read_story_cps(
|
|
104
|
+
table_stream,
|
|
105
|
+
offset=offset,
|
|
106
|
+
size=size,
|
|
107
|
+
ccp_headers=ccp_headers,
|
|
108
|
+
section_count=len(section_values),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def content_units(
|
|
112
|
+
story_index: int,
|
|
113
|
+
story_name: str,
|
|
114
|
+
) -> tuple[StoryCharacter, ...] | None:
|
|
115
|
+
relative_start = story_cps[story_index]
|
|
116
|
+
relative_end = story_cps[story_index + 1]
|
|
117
|
+
if relative_start == relative_end:
|
|
118
|
+
return None
|
|
119
|
+
cp_start = header_story_cp_start + relative_start
|
|
120
|
+
cp_end = header_story_cp_start + relative_end
|
|
121
|
+
units = piece_table.extract_characters(
|
|
122
|
+
cp_start,
|
|
123
|
+
cp_end,
|
|
124
|
+
report,
|
|
125
|
+
story=story_name,
|
|
126
|
+
)
|
|
127
|
+
if not units or units[-1].text != "\r" or units[-1].cp_end != cp_end:
|
|
128
|
+
raise InvalidWordDocument(
|
|
129
|
+
f"non-empty PlcfHdd story {story_index} has no guard paragraph mark"
|
|
130
|
+
)
|
|
131
|
+
return units[:-1]
|
|
132
|
+
|
|
133
|
+
nonempty_separators = 0
|
|
134
|
+
for story_index in range(6):
|
|
135
|
+
if content_units(story_index, f"note-separator-{story_index}") is not None:
|
|
136
|
+
nonempty_separators += 1
|
|
137
|
+
if nonempty_separators:
|
|
138
|
+
report.warning(
|
|
139
|
+
"NOTE_SEPARATOR_STORIES_DEFERRED",
|
|
140
|
+
"footnote or endnote separator stories are not yet emitted",
|
|
141
|
+
location=SourceLocation(story="headers"),
|
|
142
|
+
story_count=nonempty_separators,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
parsed_story_count = 0
|
|
146
|
+
paragraph_count = 0
|
|
147
|
+
resolved_sections: list[SectionProperties] = []
|
|
148
|
+
for section_index, section in enumerate(section_values):
|
|
149
|
+
replacements: dict[str, HeaderFooterStory] = {}
|
|
150
|
+
group_start = 6 + 6 * section_index
|
|
151
|
+
for group_index, (field_name, _display_name) in enumerate(
|
|
152
|
+
_STORIES_PER_SECTION
|
|
153
|
+
):
|
|
154
|
+
story_index = group_start + group_index
|
|
155
|
+
story_name = f"section-{section_index}-{field_name.replace('_', '-')}"
|
|
156
|
+
units = content_units(story_index, story_name)
|
|
157
|
+
if units is None:
|
|
158
|
+
continue
|
|
159
|
+
if not units or units[-1].text != "\r":
|
|
160
|
+
raise InvalidWordDocument(
|
|
161
|
+
f"non-empty header/footer story {story_index} is not a whole paragraph"
|
|
162
|
+
)
|
|
163
|
+
parsed = parse_main_story(
|
|
164
|
+
units,
|
|
165
|
+
report,
|
|
166
|
+
character_properties_at=character_properties_at,
|
|
167
|
+
paragraph_properties_at=paragraph_properties_at,
|
|
168
|
+
story_name=story_name,
|
|
169
|
+
)
|
|
170
|
+
replacements[field_name] = HeaderFooterStory(
|
|
171
|
+
cp_start=units[0].cp_start,
|
|
172
|
+
cp_end=units[-1].cp_end,
|
|
173
|
+
paragraphs=parsed.paragraphs,
|
|
174
|
+
blocks=parsed.blocks,
|
|
175
|
+
)
|
|
176
|
+
parsed_story_count += 1
|
|
177
|
+
paragraph_count += len(parsed.paragraphs)
|
|
178
|
+
resolved_sections.append(replace(section, **replacements))
|
|
179
|
+
|
|
180
|
+
return HeaderFooterCollection(
|
|
181
|
+
sections=tuple(resolved_sections),
|
|
182
|
+
story_count=parsed_story_count,
|
|
183
|
+
paragraph_count=paragraph_count,
|
|
184
|
+
)
|