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/msdoc/sprm.py ADDED
@@ -0,0 +1,753 @@
1
+ """Bounded parsing and application of supported MS-DOC property modifiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+ import struct
7
+ from typing import Callable
8
+
9
+ from ..errors import InvalidWordDocument
10
+ from ..model import (
11
+ BorderProperties,
12
+ CharacterProperties,
13
+ ParagraphProperties,
14
+ ShadingProperties,
15
+ TableBorders,
16
+ TableCellDefinition,
17
+ TableCellMarginOverride,
18
+ TableRowProperties,
19
+ )
20
+
21
+
22
+ @dataclass(slots=True, frozen=True)
23
+ class PropertyModifier:
24
+ opcode: int
25
+ operand: bytes
26
+
27
+
28
+ _FIXED_OPERAND_LENGTHS = {0: 1, 1: 1, 2: 2, 3: 4, 4: 2, 5: 2, 7: 3}
29
+
30
+
31
+ def parse_grpprl(
32
+ data: bytes,
33
+ *,
34
+ label: str,
35
+ allow_trailing_zero_padding: bool = False,
36
+ ) -> tuple[PropertyModifier, ...]:
37
+ """Parse a complete grpprl, using Sprm.spra to bound every operand."""
38
+
39
+ modifiers: list[PropertyModifier] = []
40
+ position = 0
41
+ while position < len(data):
42
+ if len(data) - position < 2:
43
+ if allow_trailing_zero_padding and data[position:] == b"\0":
44
+ break
45
+ raise InvalidWordDocument(
46
+ f"{label} ends with a truncated Sprm at byte {position}"
47
+ )
48
+ opcode = struct.unpack_from("<H", data, position)[0]
49
+ position += 2
50
+ spra = opcode >> 13
51
+ if spra == 6:
52
+ if position >= len(data):
53
+ raise InvalidWordDocument(
54
+ f"{label} Sprm 0x{opcode:04X} has no variable-length prefix"
55
+ )
56
+ if opcode == 0xD608:
57
+ if position > len(data) - 2:
58
+ raise InvalidWordDocument(
59
+ f"{label} sprmTDefTable has no 16-bit length prefix"
60
+ )
61
+ byte_count = struct.unpack_from("<H", data, position)[0]
62
+ # TDefTableOperand.cb counts the remainder incremented by one;
63
+ # including the two-byte cb itself gives cb+1 operand bytes.
64
+ operand_length = byte_count + 1
65
+ else:
66
+ byte_count = data[position]
67
+ operand_length = 1 + byte_count
68
+ # sprmPChgTabs permits 0xFF with an implicit size. We do not yet
69
+ # consume tab-stop operands, so retaining the remainder as one
70
+ # opaque operand is safer than guessing boundaries.
71
+ if opcode == 0xC615 and byte_count == 0xFF:
72
+ modifiers.append(PropertyModifier(opcode, data[position:]))
73
+ position = len(data)
74
+ continue
75
+ else:
76
+ operand_length = _FIXED_OPERAND_LENGTHS.get(spra)
77
+ if operand_length is None:
78
+ raise InvalidWordDocument(
79
+ f"{label} Sprm 0x{opcode:04X} has invalid spra {spra}"
80
+ )
81
+ operand_end = position + operand_length
82
+ if operand_end > len(data):
83
+ raise InvalidWordDocument(
84
+ f"{label} Sprm 0x{opcode:04X} operand exceeds grpprl"
85
+ )
86
+ modifiers.append(PropertyModifier(opcode, data[position:operand_end]))
87
+ position = operand_end
88
+ return tuple(modifiers)
89
+
90
+
91
+ _CHARACTER_TOGGLES = {
92
+ 0x0835: "bold",
93
+ 0x0836: "italic",
94
+ 0x0837: "strike",
95
+ 0x083A: "small_caps",
96
+ 0x083B: "caps",
97
+ 0x083C: "hidden",
98
+ 0x2A53: "double_strike",
99
+ }
100
+
101
+ _UNDERLINES = {
102
+ 0x00: "none",
103
+ 0x01: "single",
104
+ 0x02: "words",
105
+ 0x03: "double",
106
+ 0x04: "dotted",
107
+ 0x06: "thick",
108
+ 0x07: "dash",
109
+ 0x09: "dotDash",
110
+ 0x0A: "dotDotDash",
111
+ 0x0B: "wave",
112
+ 0x14: "dottedHeavy",
113
+ 0x17: "dashedHeavy",
114
+ 0x19: "dashDotHeavy",
115
+ 0x1A: "dashDotDotHeavy",
116
+ 0x1B: "wavyHeavy",
117
+ 0x27: "dashLong",
118
+ 0x2B: "wavyDouble",
119
+ 0x37: "dashLongHeavy",
120
+ }
121
+
122
+ _ICO_RGB = {
123
+ 0x01: "000000",
124
+ 0x02: "0000FF",
125
+ 0x03: "00FFFF",
126
+ 0x04: "00FF00",
127
+ 0x05: "FF00FF",
128
+ 0x06: "FF0000",
129
+ 0x07: "FFFF00",
130
+ 0x08: "FFFFFF",
131
+ 0x09: "000080",
132
+ 0x0A: "008080",
133
+ 0x0B: "008000",
134
+ 0x0C: "800080",
135
+ 0x0D: "800000",
136
+ 0x0E: "808000",
137
+ 0x0F: "808080",
138
+ 0x10: "C0C0C0",
139
+ }
140
+
141
+ _ICO_HIGHLIGHT = {
142
+ 0x00: "none",
143
+ 0x01: "black",
144
+ 0x02: "blue",
145
+ 0x03: "cyan",
146
+ 0x04: "green",
147
+ 0x05: "magenta",
148
+ 0x06: "red",
149
+ 0x07: "yellow",
150
+ 0x08: "white",
151
+ 0x09: "darkBlue",
152
+ 0x0A: "darkCyan",
153
+ 0x0B: "darkGreen",
154
+ 0x0C: "darkMagenta",
155
+ 0x0D: "darkRed",
156
+ 0x0E: "darkYellow",
157
+ 0x0F: "darkGray",
158
+ 0x10: "lightGray",
159
+ }
160
+
161
+ _BORDER_STYLES = {
162
+ 0x00: "none",
163
+ 0x01: "single",
164
+ 0x03: "double",
165
+ 0x05: "single",
166
+ 0x06: "dotted",
167
+ 0x07: "dashed",
168
+ 0x08: "dotDash",
169
+ 0x09: "dotDotDash",
170
+ 0x0A: "triple",
171
+ 0x0B: "thinThickSmallGap",
172
+ 0x0C: "thickThinSmallGap",
173
+ 0x0D: "thinThickThinSmallGap",
174
+ 0x0E: "thinThickMediumGap",
175
+ 0x0F: "thickThinMediumGap",
176
+ 0x10: "thinThickThinMediumGap",
177
+ 0x11: "thinThickLargeGap",
178
+ 0x12: "thickThinLargeGap",
179
+ 0x13: "thinThickThinLargeGap",
180
+ 0x14: "wave",
181
+ 0x15: "doubleWave",
182
+ 0x16: "dashSmallGap",
183
+ 0x17: "dashDotStroked",
184
+ 0x18: "threeDEmboss",
185
+ 0x19: "threeDEngrave",
186
+ 0x1A: "outset",
187
+ 0x1B: "inset",
188
+ }
189
+
190
+ _SHADING_PATTERNS = {
191
+ 0x00: "clear",
192
+ 0x01: "solid",
193
+ 0x02: "pct5",
194
+ 0x03: "pct10",
195
+ 0x04: "pct20",
196
+ 0x05: "pct25",
197
+ 0x06: "pct30",
198
+ 0x07: "pct40",
199
+ 0x08: "pct50",
200
+ 0x09: "pct60",
201
+ 0x0A: "pct70",
202
+ 0x0B: "pct75",
203
+ 0x0C: "pct80",
204
+ 0x0D: "pct90",
205
+ 0x0E: "horzStripe",
206
+ 0x0F: "vertStripe",
207
+ 0x10: "reverseDiagStripe",
208
+ 0x11: "diagStripe",
209
+ 0x12: "horzCross",
210
+ 0x13: "diagCross",
211
+ 0x14: "thinHorzStripe",
212
+ 0x15: "thinVertStripe",
213
+ 0x16: "thinReverseDiagStripe",
214
+ 0x17: "thinDiagStripe",
215
+ 0x18: "thinHorzCross",
216
+ 0x19: "thinDiagCross",
217
+ 0x25: "pct12",
218
+ 0x26: "pct15",
219
+ 0x2B: "pct35",
220
+ 0x2E: "pct45",
221
+ 0x31: "pct55",
222
+ 0x33: "pct62",
223
+ 0x34: "pct65",
224
+ 0x39: "pct85",
225
+ 0x3C: "pct95",
226
+ }
227
+
228
+ _CELL_MARGIN_SIDES = (
229
+ (0x01, "top"),
230
+ (0x02, "left"),
231
+ (0x04, "bottom"),
232
+ (0x08, "right"),
233
+ )
234
+
235
+
236
+ def _parse_brc80(data: bytes) -> BorderProperties | None:
237
+ if len(data) != 4:
238
+ raise InvalidWordDocument("Brc80 must contain exactly four bytes")
239
+ if data == b"\xFF\xFF\xFF\xFF" or data[1] in (0x00, 0xFF):
240
+ return None
241
+ style = _BORDER_STYLES.get(data[1])
242
+ if style is None:
243
+ return None
244
+ color = "auto" if data[2] == 0 else _ICO_RGB.get(data[2], "auto")
245
+ return BorderProperties(
246
+ style=style,
247
+ size_eighth_points=max(data[0], 2),
248
+ color=color,
249
+ space_points=data[3] & 0x1F,
250
+ shadow=bool(data[3] & 0x20),
251
+ frame=bool(data[3] & 0x40),
252
+ )
253
+
254
+
255
+ def _parse_table_borders80(operand: bytes) -> TableBorders:
256
+ if len(operand) != 25 or operand[0] != 0x18:
257
+ raise InvalidWordDocument("sprmTTableBorders80 operand must contain 24 bytes")
258
+ values = [
259
+ _parse_brc80(operand[1 + index * 4 : 5 + index * 4])
260
+ for index in range(6)
261
+ ]
262
+ return TableBorders(*values)
263
+
264
+
265
+ def _parse_cssa(
266
+ operand: bytes,
267
+ ) -> tuple[int, int, tuple[str, ...], int | None]:
268
+ if len(operand) != 7 or operand[0] != 6:
269
+ raise InvalidWordDocument("CSSAOperand must contain exactly six data bytes")
270
+ first_cell, limit_cell, side_flags, width_type, width = struct.unpack_from(
271
+ "<BBBBH",
272
+ operand,
273
+ 1,
274
+ )
275
+ if first_cell > limit_cell or limit_cell > 63:
276
+ raise InvalidWordDocument("CSSAOperand contains an invalid cell range")
277
+ if not side_flags or side_flags & ~0x0F:
278
+ raise InvalidWordDocument("CSSAOperand contains invalid cell sides")
279
+ if width_type not in (0, 3):
280
+ raise InvalidWordDocument("CSSAOperand contains an unsupported width type")
281
+ if width_type == 0 and width:
282
+ raise InvalidWordDocument("CSSAOperand ftsNil width must be zero")
283
+ if width > 31680:
284
+ raise InvalidWordDocument("CSSAOperand cell margin exceeds 22 inches")
285
+ sides = tuple(name for mask, name in _CELL_MARGIN_SIDES if side_flags & mask)
286
+ return first_cell, limit_cell, sides, width if width_type == 3 else None
287
+
288
+
289
+ def _parse_shd80(data: bytes) -> tuple[ShadingProperties | None, bool]:
290
+ if len(data) != 2:
291
+ raise InvalidWordDocument("Shd80 must contain exactly two bytes")
292
+ value = struct.unpack("<H", data)[0]
293
+ if value in (0x0000, 0xFFFF):
294
+ return None, False
295
+ foreground_index = value & 0x1F
296
+ background_index = (value >> 5) & 0x1F
297
+ pattern_index = (value >> 10) & 0x3F
298
+ pattern = _SHADING_PATTERNS.get(pattern_index)
299
+ unsupported = pattern is None
300
+ if pattern is None:
301
+ pattern = "clear"
302
+ foreground = (
303
+ "auto" if foreground_index == 0 else _ICO_RGB.get(foreground_index, "auto")
304
+ )
305
+ background = (
306
+ "auto" if background_index == 0 else _ICO_RGB.get(background_index, "auto")
307
+ )
308
+ unsupported = unsupported or foreground_index > 0x10 or background_index > 0x10
309
+ return ShadingProperties(pattern, foreground, background), unsupported
310
+
311
+
312
+ def _parse_def_table_shd80(
313
+ operand: bytes,
314
+ ) -> tuple[tuple[ShadingProperties | None, ...], bool]:
315
+ if not operand or operand[0] != len(operand) - 1 or operand[0] % 2:
316
+ raise InvalidWordDocument("DefTableShd80Operand has an invalid byte count")
317
+ if operand[0] > 126:
318
+ raise InvalidWordDocument("DefTableShd80Operand has too many cells")
319
+ values: list[ShadingProperties | None] = []
320
+ unsupported = False
321
+ for position in range(1, len(operand), 2):
322
+ shading, approximated = _parse_shd80(operand[position : position + 2])
323
+ values.append(shading)
324
+ unsupported = unsupported or approximated
325
+ return tuple(values), unsupported
326
+
327
+
328
+ def _parse_tdef_table(operand: bytes) -> TableRowProperties:
329
+ if len(operand) < 3:
330
+ raise InvalidWordDocument("sprmTDefTable operand is truncated")
331
+ byte_count = struct.unpack_from("<H", operand)[0]
332
+ if len(operand) != byte_count + 1:
333
+ raise InvalidWordDocument(
334
+ f"sprmTDefTable cb {byte_count} does not match {len(operand)} bytes"
335
+ )
336
+ column_count = operand[2]
337
+ if column_count > 63:
338
+ raise InvalidWordDocument(
339
+ f"sprmTDefTable has invalid column count {column_count}"
340
+ )
341
+ boundaries_end = 3 + 2 * (column_count + 1)
342
+ if boundaries_end > len(operand):
343
+ raise InvalidWordDocument("sprmTDefTable column boundaries are truncated")
344
+ boundaries = struct.unpack_from(f"<{column_count + 1}h", operand, 3)
345
+ if any(current < previous for previous, current in zip(boundaries, boundaries[1:])):
346
+ raise InvalidWordDocument("sprmTDefTable column boundaries are decreasing")
347
+
348
+ descriptor_data = operand[boundaries_end:]
349
+ if len(descriptor_data) % 20:
350
+ raise InvalidWordDocument("sprmTDefTable TC80 array is truncated")
351
+ descriptor_count = min(len(descriptor_data) // 20, column_count)
352
+ definitions: list[TableCellDefinition] = []
353
+ for index in range(descriptor_count):
354
+ start = index * 20
355
+ tcgrf, preferred_width = struct.unpack_from("<HH", descriptor_data, start)
356
+ horizontal_value = tcgrf & 0x03
357
+ vertical_value = (tcgrf >> 5) & 0x03
358
+ alignment_value = (tcgrf >> 7) & 0x03
359
+ width_type = (tcgrf >> 9) & 0x07
360
+ definitions.append(
361
+ TableCellDefinition(
362
+ preferred_width_twips=(
363
+ preferred_width if width_type == 3 else None
364
+ ),
365
+ horizontal_merge=(
366
+ "continue"
367
+ if horizontal_value == 1
368
+ else "restart" if horizontal_value in (2, 3) else None
369
+ ),
370
+ vertical_merge={1: "continue", 3: "restart"}.get(vertical_value),
371
+ vertical_alignment={1: "center", 2: "bottom"}.get(
372
+ alignment_value
373
+ ),
374
+ fit_text=True if tcgrf & 0x1000 else None,
375
+ no_wrap=True if tcgrf & 0x2000 else None,
376
+ borders=TableBorders(
377
+ top=_parse_brc80(descriptor_data[start + 4 : start + 8]),
378
+ left=_parse_brc80(descriptor_data[start + 8 : start + 12]),
379
+ bottom=_parse_brc80(descriptor_data[start + 12 : start + 16]),
380
+ right=_parse_brc80(descriptor_data[start + 16 : start + 20]),
381
+ ),
382
+ )
383
+ )
384
+ definitions.extend(
385
+ TableCellDefinition() for _ in range(column_count - len(definitions))
386
+ )
387
+ return TableRowProperties(
388
+ cell_boundaries_twips=tuple(boundaries),
389
+ cell_definitions=tuple(definitions),
390
+ )
391
+
392
+
393
+ def apply_character_modifiers(
394
+ modifiers: tuple[PropertyModifier, ...],
395
+ *,
396
+ initial_properties: CharacterProperties | None = None,
397
+ base_properties: CharacterProperties | None = None,
398
+ font_names: dict[int, str] | None = None,
399
+ style_properties_at: Callable[[int], CharacterProperties] | None = None,
400
+ ) -> tuple[CharacterProperties, set[int], int]:
401
+ properties = initial_properties or CharacterProperties()
402
+ paragraph_style_properties = base_properties or CharacterProperties()
403
+ style_baseline = paragraph_style_properties
404
+ font_names = font_names or {}
405
+ unsupported: set[int] = set()
406
+ style_relative_toggle_count = 0
407
+ for modifier in modifiers:
408
+ opcode = modifier.opcode
409
+ operand = modifier.operand
410
+ if opcode == 0x2A33: # sprmCPlain
411
+ properties = CharacterProperties()
412
+ style_baseline = paragraph_style_properties
413
+ elif opcode == 0x4A30: # sprmCIstd
414
+ style_id = struct.unpack("<H", operand)[0]
415
+ properties = CharacterProperties(style_id=style_id)
416
+ style_baseline = paragraph_style_properties
417
+ if style_properties_at is not None:
418
+ style_baseline = merge_character_properties(
419
+ paragraph_style_properties,
420
+ style_properties_at(style_id),
421
+ )
422
+ elif opcode in _CHARACTER_TOGGLES:
423
+ value = operand[0]
424
+ if value in (0x00, 0x01):
425
+ properties = replace(
426
+ properties,
427
+ **{_CHARACTER_TOGGLES[opcode]: bool(value)},
428
+ )
429
+ elif value in (0x80, 0x81):
430
+ style_relative_toggle_count += 1
431
+ attribute = _CHARACTER_TOGGLES[opcode]
432
+ style_value = bool(getattr(style_baseline, attribute))
433
+ properties = replace(
434
+ properties,
435
+ **{attribute: style_value if value == 0x80 else not style_value},
436
+ )
437
+ else:
438
+ unsupported.add(opcode)
439
+ elif opcode in (0x4A4F, 0x4A50, 0x4A51, 0x4A5E):
440
+ font_index = struct.unpack("<H", operand)[0]
441
+ font_name = font_names.get(font_index)
442
+ if font_name is None:
443
+ unsupported.add(opcode)
444
+ continue
445
+ attribute = {
446
+ 0x4A4F: "ascii_font",
447
+ 0x4A50: "east_asia_font",
448
+ 0x4A51: "high_ansi_font",
449
+ 0x4A5E: "complex_script_font",
450
+ }[opcode]
451
+ properties = replace(properties, **{attribute: font_name})
452
+ elif opcode == 0x2A3E:
453
+ underline = _UNDERLINES.get(operand[0])
454
+ if underline is None:
455
+ unsupported.add(opcode)
456
+ else:
457
+ properties = replace(properties, underline=underline)
458
+ elif opcode == 0x2A42:
459
+ if operand[0] == 0:
460
+ properties = replace(properties, color="auto")
461
+ elif operand[0] in _ICO_RGB:
462
+ properties = replace(properties, color=_ICO_RGB[operand[0]])
463
+ else:
464
+ unsupported.add(opcode)
465
+ elif opcode == 0x2A0C:
466
+ highlight = _ICO_HIGHLIGHT.get(operand[0])
467
+ if highlight is None:
468
+ unsupported.add(opcode)
469
+ else:
470
+ properties = replace(properties, highlight=highlight)
471
+ elif opcode == 0x4A43:
472
+ size = struct.unpack("<H", operand)[0]
473
+ if 2 <= size <= 3276:
474
+ properties = replace(properties, size_half_points=size)
475
+ else:
476
+ unsupported.add(opcode)
477
+ elif opcode == 0x4845:
478
+ position = struct.unpack("<h", operand)[0]
479
+ properties = replace(properties, position_half_points=position)
480
+ elif opcode == 0x2A48:
481
+ vertical_align = {0: "baseline", 1: "superscript", 2: "subscript"}.get(
482
+ operand[0]
483
+ )
484
+ if vertical_align is None:
485
+ unsupported.add(opcode)
486
+ else:
487
+ properties = replace(properties, vertical_align=vertical_align)
488
+ elif opcode == 0x6870:
489
+ if operand[3] == 0xFF:
490
+ properties = replace(properties, color="auto")
491
+ else:
492
+ properties = replace(
493
+ properties,
494
+ color=f"{operand[0]:02X}{operand[1]:02X}{operand[2]:02X}",
495
+ )
496
+ else:
497
+ unsupported.add(opcode)
498
+ return properties, unsupported, style_relative_toggle_count
499
+
500
+
501
+ _JUSTIFICATION = {
502
+ 0: "left",
503
+ 1: "center",
504
+ 2: "right",
505
+ 3: "both",
506
+ 4: "distribute",
507
+ 5: "mediumKashida",
508
+ 7: "highKashida",
509
+ 8: "lowKashida",
510
+ 9: "thaiDistribute",
511
+ }
512
+
513
+
514
+ def apply_paragraph_modifiers(
515
+ modifiers: tuple[PropertyModifier, ...],
516
+ *,
517
+ style_id: int | None,
518
+ initial_properties: ParagraphProperties | None = None,
519
+ ) -> tuple[ParagraphProperties, set[int]]:
520
+ properties = initial_properties or ParagraphProperties(style_id=style_id)
521
+ unsupported: set[int] = set()
522
+ for modifier in modifiers:
523
+ opcode = modifier.opcode
524
+ operand = modifier.operand
525
+ if opcode == 0x4600:
526
+ properties = replace(
527
+ properties,
528
+ style_id=struct.unpack("<H", operand)[0],
529
+ )
530
+ elif opcode == 0x2416:
531
+ properties = replace(properties, in_table=bool(operand[0]))
532
+ elif opcode == 0x2417:
533
+ properties = replace(properties, table_terminating=bool(operand[0]))
534
+ elif opcode == 0x6649:
535
+ depth = struct.unpack("<i", operand)[0]
536
+ if depth < 0:
537
+ unsupported.add(opcode)
538
+ else:
539
+ properties = replace(properties, table_depth=depth)
540
+ elif opcode == 0x664A:
541
+ delta = struct.unpack("<i", operand)[0]
542
+ depth = properties.effective_table_depth + delta
543
+ if depth < 0:
544
+ unsupported.add(opcode)
545
+ else:
546
+ properties = replace(properties, table_depth=depth)
547
+ elif opcode == 0x244B:
548
+ properties = replace(properties, inner_table_cell=bool(operand[0]))
549
+ elif opcode == 0x244C:
550
+ properties = replace(properties, inner_table_row=bool(operand[0]))
551
+ elif opcode == 0x5400:
552
+ alignment = {0: "left", 1: "center", 2: "right"}.get(
553
+ struct.unpack("<H", operand)[0]
554
+ )
555
+ if alignment is None:
556
+ unsupported.add(opcode)
557
+ else:
558
+ row = properties.table_row or TableRowProperties()
559
+ properties = replace(
560
+ properties,
561
+ table_row=replace(row, alignment=alignment),
562
+ )
563
+ elif opcode == 0x9601:
564
+ row = properties.table_row or TableRowProperties()
565
+ properties = replace(
566
+ properties,
567
+ table_row=replace(
568
+ row,
569
+ left_indent_twips=struct.unpack("<h", operand)[0],
570
+ ),
571
+ )
572
+ elif opcode == 0x9602:
573
+ gap = struct.unpack("<h", operand)[0]
574
+ if gap < 0:
575
+ unsupported.add(opcode)
576
+ else:
577
+ row = properties.table_row or TableRowProperties()
578
+ properties = replace(
579
+ properties,
580
+ table_row=replace(row, gap_half_twips=gap),
581
+ )
582
+ elif opcode in (0x3403, 0x3466):
583
+ row = properties.table_row or TableRowProperties()
584
+ properties = replace(
585
+ properties,
586
+ table_row=replace(row, cant_split=bool(operand[0])),
587
+ )
588
+ elif opcode == 0x3404:
589
+ row = properties.table_row or TableRowProperties()
590
+ properties = replace(
591
+ properties,
592
+ table_row=replace(row, is_header=bool(operand[0])),
593
+ )
594
+ elif opcode == 0x9407:
595
+ height = struct.unpack("<h", operand)[0]
596
+ row = properties.table_row or TableRowProperties()
597
+ properties = replace(
598
+ properties,
599
+ table_row=replace(
600
+ row,
601
+ height_twips=abs(height) if height else None,
602
+ height_rule="exact" if height < 0 else "atLeast",
603
+ ),
604
+ )
605
+ elif opcode == 0xD605:
606
+ row = properties.table_row or TableRowProperties()
607
+ properties = replace(
608
+ properties,
609
+ table_row=replace(row, borders=_parse_table_borders80(operand)),
610
+ )
611
+ elif opcode == 0xD608:
612
+ definition = _parse_tdef_table(operand)
613
+ row = properties.table_row or TableRowProperties()
614
+ properties = replace(
615
+ properties,
616
+ table_row=replace(
617
+ row,
618
+ cell_boundaries_twips=definition.cell_boundaries_twips,
619
+ cell_definitions=definition.cell_definitions,
620
+ ),
621
+ )
622
+ elif opcode == 0xD609:
623
+ shadings, approximated = _parse_def_table_shd80(operand)
624
+ row = properties.table_row or TableRowProperties()
625
+ properties = replace(
626
+ properties,
627
+ table_row=replace(row, cell_shadings=shadings),
628
+ )
629
+ if approximated:
630
+ unsupported.add(opcode)
631
+ elif opcode in (0xD632, 0xD634):
632
+ first_cell, limit_cell, sides, width = _parse_cssa(operand)
633
+ row = properties.table_row or TableRowProperties()
634
+ if opcode == 0xD634:
635
+ if (first_cell, limit_cell) != (0, 1):
636
+ unsupported.add(opcode)
637
+ else:
638
+ margins = replace(
639
+ row.default_cell_margins,
640
+ **{side: width for side in sides},
641
+ )
642
+ properties = replace(
643
+ properties,
644
+ table_row=replace(row, default_cell_margins=margins),
645
+ )
646
+ else:
647
+ override = TableCellMarginOverride(
648
+ first_cell,
649
+ limit_cell,
650
+ sides,
651
+ width,
652
+ )
653
+ properties = replace(
654
+ properties,
655
+ table_row=replace(
656
+ row,
657
+ cell_margin_overrides=(
658
+ *row.cell_margin_overrides,
659
+ override,
660
+ ),
661
+ ),
662
+ )
663
+ elif opcode in (0x2403, 0x2461):
664
+ justification = _JUSTIFICATION.get(operand[0])
665
+ if justification is None:
666
+ unsupported.add(opcode)
667
+ else:
668
+ properties = replace(properties, justification=justification)
669
+ elif opcode == 0x2405:
670
+ properties = replace(properties, keep_lines=bool(operand[0]))
671
+ elif opcode == 0x2406:
672
+ properties = replace(properties, keep_next=bool(operand[0]))
673
+ elif opcode == 0x2407:
674
+ properties = replace(properties, page_break_before=bool(operand[0]))
675
+ elif opcode in (0x840F, 0x845E):
676
+ properties = replace(
677
+ properties,
678
+ left_indent_twips=struct.unpack("<h", operand)[0],
679
+ )
680
+ elif opcode in (0x840E, 0x845D):
681
+ properties = replace(
682
+ properties,
683
+ right_indent_twips=struct.unpack("<h", operand)[0],
684
+ )
685
+ elif opcode in (0x8411, 0x8460):
686
+ properties = replace(
687
+ properties,
688
+ first_line_indent_twips=struct.unpack("<h", operand)[0],
689
+ )
690
+ elif opcode == 0xA413:
691
+ properties = replace(
692
+ properties,
693
+ space_before_twips=struct.unpack("<H", operand)[0],
694
+ )
695
+ elif opcode == 0xA414:
696
+ properties = replace(
697
+ properties,
698
+ space_after_twips=struct.unpack("<H", operand)[0],
699
+ )
700
+ elif opcode == 0x6412:
701
+ raw_line, multiple = struct.unpack("<HH", operand)
702
+ signed_line = struct.unpack("<h", operand[:2])[0]
703
+ if multiple == 1 and signed_line >= 0:
704
+ properties = replace(
705
+ properties,
706
+ line_spacing_twips=raw_line,
707
+ line_rule="auto",
708
+ )
709
+ elif multiple == 0 and signed_line < 0:
710
+ properties = replace(
711
+ properties,
712
+ line_spacing_twips=-signed_line,
713
+ line_rule="exact",
714
+ )
715
+ elif multiple == 0:
716
+ properties = replace(
717
+ properties,
718
+ line_spacing_twips=raw_line,
719
+ line_rule="atLeast",
720
+ )
721
+ else:
722
+ unsupported.add(opcode)
723
+ else:
724
+ unsupported.add(opcode)
725
+ return properties, unsupported
726
+
727
+
728
+ def merge_character_properties(
729
+ base: CharacterProperties,
730
+ overlay: CharacterProperties,
731
+ ) -> CharacterProperties:
732
+ """Overlay only explicitly specified character-property values."""
733
+
734
+ updates = {
735
+ field_name: getattr(overlay, field_name)
736
+ for field_name in CharacterProperties.__dataclass_fields__
737
+ if getattr(overlay, field_name) is not None
738
+ }
739
+ return replace(base, **updates) if updates else base
740
+
741
+
742
+ def merge_paragraph_properties(
743
+ base: ParagraphProperties,
744
+ overlay: ParagraphProperties,
745
+ ) -> ParagraphProperties:
746
+ """Overlay only explicitly specified paragraph-property values."""
747
+
748
+ updates = {
749
+ field_name: getattr(overlay, field_name)
750
+ for field_name in ParagraphProperties.__dataclass_fields__
751
+ if getattr(overlay, field_name) is not None
752
+ }
753
+ return replace(base, **updates) if updates else base