uniword 1.0.9 → 1.0.11

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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/data/themes/office_format_scheme.xml +137 -0
  3. data/data/themes/office_theme.xml +2 -0
  4. data/lib/uniword/builder/footnote_builder.rb +4 -2
  5. data/lib/uniword/builder/run_builder.rb +5 -0
  6. data/lib/uniword/document_factory.rb +1 -0
  7. data/lib/uniword/docx/package.rb +18 -0
  8. data/lib/uniword/docx/package_defaults.rb +1 -0
  9. data/lib/uniword/docx/package_serialization.rb +38 -0
  10. data/lib/uniword/docx/reconciler.rb +195 -13
  11. data/lib/uniword/lazy_loader.rb +1 -1
  12. data/lib/uniword/properties/boolean_element_factory.rb +8 -15
  13. data/lib/uniword/schema/schema_loader.rb +1 -1
  14. data/lib/uniword/transformation/mhtml_style_builder.rb +9 -2
  15. data/lib/uniword/transformation/ooxml_to_mhtml_converter.rb +7 -15
  16. data/lib/uniword/transformation/yaml_css_generator.rb +815 -0
  17. data/lib/uniword/transformation.rb +2 -0
  18. data/lib/uniword/version.rb +1 -1
  19. data/lib/uniword/wordprocessingml/attached_template.rb +18 -0
  20. data/lib/uniword/wordprocessingml/body.rb +34 -34
  21. data/lib/uniword/wordprocessingml/brk_bin.rb +17 -0
  22. data/lib/uniword/wordprocessingml/brk_bin_sub.rb +17 -0
  23. data/lib/uniword/wordprocessingml/character_spacing_control.rb +17 -0
  24. data/lib/uniword/wordprocessingml/clr_scheme_mapping.rb +39 -0
  25. data/lib/uniword/wordprocessingml/compat.rb +16 -0
  26. data/lib/uniword/wordprocessingml/decimal_symbol.rb +17 -0
  27. data/lib/uniword/wordprocessingml/def_jc.rb +17 -0
  28. data/lib/uniword/wordprocessingml/default_tab_stop.rb +17 -0
  29. data/lib/uniword/wordprocessingml/disp_def.rb +14 -0
  30. data/lib/uniword/wordprocessingml/do_not_display_page_boundaries.rb +14 -0
  31. data/lib/uniword/wordprocessingml/do_not_include_subdocs_in_stats.rb +17 -0
  32. data/lib/uniword/wordprocessingml/doc_vars.rb +36 -0
  33. data/lib/uniword/wordprocessingml/document_root.rb +8 -5
  34. data/lib/uniword/wordprocessingml/endnote_pr.rb +18 -0
  35. data/lib/uniword/wordprocessingml/endnotes.rb +18 -0
  36. data/lib/uniword/wordprocessingml/even_and_odd_headers.rb +17 -0
  37. data/lib/uniword/wordprocessingml/footnote_pos.rb +17 -0
  38. data/lib/uniword/wordprocessingml/footnote_pr.rb +20 -0
  39. data/lib/uniword/wordprocessingml/footnotes.rb +18 -0
  40. data/lib/uniword/wordprocessingml/hdr_shape_defaults.rb +19 -0
  41. data/lib/uniword/wordprocessingml/hyphenation_zone.rb +20 -0
  42. data/lib/uniword/wordprocessingml/int_lim.rb +17 -0
  43. data/lib/uniword/wordprocessingml/l_margin.rb +17 -0
  44. data/lib/uniword/wordprocessingml/list_separator.rb +17 -0
  45. data/lib/uniword/wordprocessingml/math_font.rb +17 -0
  46. data/lib/uniword/wordprocessingml/math_pr.rb +49 -0
  47. data/lib/uniword/wordprocessingml/mirror_margins.rb +17 -0
  48. data/lib/uniword/wordprocessingml/nary_lim.rb +17 -0
  49. data/lib/uniword/wordprocessingml/proof_state.rb +19 -0
  50. data/lib/uniword/wordprocessingml/r_margin.rb +17 -0
  51. data/lib/uniword/wordprocessingml/rsid.rb +17 -0
  52. data/lib/uniword/wordprocessingml/rsid_root.rb +17 -0
  53. data/lib/uniword/wordprocessingml/rsids.rb +22 -0
  54. data/lib/uniword/wordprocessingml/settings.rb +45 -511
  55. data/lib/uniword/wordprocessingml/shape_defaults.rb +21 -0
  56. data/lib/uniword/wordprocessingml/small_frac.rb +17 -0
  57. data/lib/uniword/wordprocessingml/style_pane_format_filter.rb +50 -0
  58. data/lib/uniword/wordprocessingml/style_pane_sort_method.rb +20 -0
  59. data/lib/uniword/wordprocessingml/table_cell.rb +6 -0
  60. data/lib/uniword/wordprocessingml/table_row_properties.rb +2 -0
  61. data/lib/uniword/wordprocessingml/theme_font_lang.rb +19 -0
  62. data/lib/uniword/wordprocessingml/w14_doc_id.rb +17 -0
  63. data/lib/uniword/wordprocessingml/w15_chart_tracking_ref_based.rb +14 -0
  64. data/lib/uniword/wordprocessingml/w15_doc_id.rb +17 -0
  65. data/lib/uniword/wordprocessingml/wrap_indent.rb +17 -0
  66. data/lib/uniword/wordprocessingml.rb +42 -56
  67. data/lib/uniword.rb +12 -5
  68. metadata +50 -2
@@ -0,0 +1,815 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uniword
4
+ module Transformation
5
+ # Generates Word HTML4 CSS from YAML style configuration hashes.
6
+ #
7
+ # Converts OOXML style definitions (paragraph/character properties) to CSS
8
+ # rules suitable for MHTML output. Produces @font-face declarations,
9
+ # paragraph/heading/character style rules, @page definitions, and @list
10
+ # definitions.
11
+ #
12
+ # @example Generate CSS from YAML configs
13
+ # generator = YamlCssGenerator.new(
14
+ # styles: YAML.load_file("styles.yml"),
15
+ # doc_defaults: YAML.load_file("doc_defaults.yml"),
16
+ # numbering: YAML.load_file("numbering.yml")
17
+ # )
18
+ # css = generator.generate
19
+ #
20
+ # @api private
21
+ class YamlCssGenerator
22
+ # Static @font-face declarations for common ISO fonts.
23
+ FONT_FACE_LIBRARY = {
24
+ "Arial" => <<~CSS,
25
+ @font-face {
26
+ font-family: Arial;
27
+ panose-1: 2 11 6 4 2 2 2 2 2 4;
28
+ mso-font-charset: 0;
29
+ mso-generic-font-family: swiss;
30
+ mso-font-pitch: variable;
31
+ mso-font-signature: -536859905 -1073711037 9 0 511 0;
32
+ }
33
+ CSS
34
+ "Courier New" => <<~CSS,
35
+ @font-face {
36
+ font-family: "Courier New";
37
+ panose-1: 2 7 3 9 2 2 5 2 4 4;
38
+ mso-font-charset: 0;
39
+ mso-generic-font-family: roman;
40
+ mso-font-pitch: fixed;
41
+ mso-font-signature: -536859905 -1073711037 9 0 511 0;
42
+ }
43
+ CSS
44
+ "Cambria Math" => <<~CSS,
45
+ @font-face {
46
+ font-family: "Cambria Math";
47
+ panose-1: 2 4 5 3 5 4 6 3 2 4;
48
+ mso-font-charset: 0;
49
+ mso-generic-font-family: roman;
50
+ mso-font-pitch: variable;
51
+ mso-font-signature: -536870145 1107305727 0 0 415 0;
52
+ }
53
+ CSS
54
+ "Calibri" => <<~CSS,
55
+ @font-face {
56
+ font-family: Calibri;
57
+ panose-1: 2 15 5 2 2 2 4 3 2 4;
58
+ mso-font-charset: 0;
59
+ mso-generic-font-family: swiss;
60
+ mso-font-pitch: variable;
61
+ mso-font-signature: -536870145 1073786111 1 0 415 0;
62
+ }
63
+ CSS
64
+ "Cambria" => <<~CSS,
65
+ @font-face {
66
+ font-family: Cambria;
67
+ panose-1: 2 4 5 3 5 4 6 3 2 4;
68
+ mso-font-charset: 0;
69
+ mso-generic-font-family: roman;
70
+ mso-font-pitch: variable;
71
+ mso-font-signature: -536870145 1073743103 0 0 415 0;
72
+ }
73
+ CSS
74
+ "Segoe UI" => <<~CSS,
75
+ @font-face {
76
+ font-family: "Segoe UI";
77
+ panose-1: 2 11 5 2 4 2 4 2 2 3;
78
+ mso-font-charset: 0;
79
+ mso-generic-font-family: swiss;
80
+ mso-font-pitch: variable;
81
+ mso-font-signature: -469750017 -1073683329 9 0 511 0;
82
+ }
83
+ CSS
84
+ "Times New Roman" => <<~CSS,
85
+ @font-face {
86
+ font-family: "Times New Roman";
87
+ panose-1: 2 2 6 3 5 4 5 2 3 4;
88
+ mso-font-charset: 0;
89
+ mso-generic-font-family: roman;
90
+ mso-font-pitch: variable;
91
+ mso-font-signature: -536870145 1073743103 0 0 415 0;
92
+ }
93
+ CSS
94
+ "SimSun" => <<~CSS,
95
+ @font-face {
96
+ font-family: "SimSun";
97
+ panose-1: 2 1 6 0 3 1 1 1 1 1;
98
+ mso-font-charset: 134;
99
+ mso-generic-font-family: auto;
100
+ mso-font-pitch: variable;
101
+ mso-font-signature: 515 135135232 16 0 262144 0;
102
+ }
103
+ CSS
104
+ "SimHei" => <<~CSS,
105
+ @font-face {
106
+ font-family: "SimHei";
107
+ panose-1: 2 1 6 9 6 1 1 1 1 1;
108
+ mso-font-charset: 134;
109
+ mso-generic-font-family: modern;
110
+ mso-font-pitch: fixed;
111
+ mso-font-signature: 515 135135232 16 0 262144 0;
112
+ }
113
+ CSS
114
+ "MS Mincho" => <<~CSS,
115
+ @font-face {
116
+ font-family: "MS Mincho";
117
+ panose-1: 2 2 6 9 4 2 5 8 3 4;
118
+ mso-font-charset: 128;
119
+ mso-generic-font-family: roman;
120
+ mso-font-pitch: fixed;
121
+ mso-font-signature: -536870145 1791491579 134217746 0 131231 0;
122
+ }
123
+ CSS
124
+ "Malgun Gothic" => <<~CSS,
125
+ @font-face {
126
+ font-family: "Malgun Gothic";
127
+ panose-1: 2 11 5 3 2 0 0 2 0 4;
128
+ mso-font-charset: 129;
129
+ mso-generic-font-family: swiss;
130
+ mso-font-pitch: variable;
131
+ mso-font-signature: -1879048145 701988091 18 0 524289 0;
132
+ }
133
+ CSS
134
+ "Cambria serif" => <<~CSS,
135
+ @font-face {
136
+ font-family: "Cambria", serif;
137
+ panose-1: 2 2 6 9 4 2 5 8 3 4;
138
+ mso-font-charset: 128;
139
+ mso-generic-font-family: roman;
140
+ mso-font-pitch: fixed;
141
+ mso-font-signature: -536870145 1791491579 134217746 0 131231 0;
142
+ }
143
+ CSS
144
+ }.freeze
145
+
146
+ # Default font-family declaration based on primary font
147
+ DEFAULT_FONT_FAMILY = '"Cambria", serif'
148
+ DEFAULT_FAREAST_FONT = '"SimSun", serif'
149
+
150
+ # @param styles [Hash] Parsed styles.yml content
151
+ # @param doc_defaults [Hash] Parsed doc_defaults.yml content
152
+ # @param numbering [Hash, nil] Parsed numbering.yml content
153
+ def initialize(styles:, doc_defaults:, numbering: nil)
154
+ style_lib = styles["style_library"] || styles
155
+ @para_styles = style_lib["paragraph_styles"] || {}
156
+ @char_styles = style_lib["character_styles"] || {}
157
+ @doc_defaults = doc_defaults["run_properties"] || {}
158
+ @numbering = numbering
159
+ @defaults = resolve_defaults
160
+ @normal_spacing_after = (@para_styles.dig("Normal", "paragraph_properties", "spacing", "after") || "6.0")
161
+ end
162
+
163
+ # Generate complete CSS string for MHTML output.
164
+ def generate
165
+ parts = []
166
+ parts << "@charset \"UTF-8\";"
167
+ parts << generate_font_faces
168
+ parts << "/* Style Definitions */"
169
+ parts << generate_all_styles
170
+ parts << generate_page_section
171
+ parts << generate_list_section
172
+ parts << generate_mso_normal_table
173
+ parts << generate_list_containers
174
+ parts << generate_ol_ul
175
+ parts.compact.join("\n")
176
+ end
177
+
178
+ private
179
+
180
+ # Resolve default run properties from doc_defaults merged with Normal style.
181
+ def resolve_defaults
182
+ normal_run = @para_styles.dig("Normal", "run_properties") || {}
183
+ merged = @doc_defaults.merge(normal_run)
184
+ {
185
+ ascii_font: merged.dig("fonts", "ascii") || "Cambria",
186
+ hansi_font: merged.dig("fonts", "hAnsi") || "Cambria",
187
+ fareast_font: merged.dig("fonts", "eastAsia") || "Calibri",
188
+ bidi_font: merged.dig("fonts", "cs") || "Times New Roman",
189
+ font_size: merged["font_size"] || "11.0",
190
+ lang: merged["lang_val"] || "en-GB",
191
+ fareast_lang: merged["lang_eastasia"] || "en-US",
192
+ }
193
+ end
194
+
195
+ # Collect all referenced fonts and generate @font-face declarations.
196
+ def generate_font_faces
197
+ fonts = Set.new
198
+ collect_fonts_from_styles(@para_styles, fonts)
199
+ collect_fonts_from_styles(@char_styles, fonts)
200
+ fonts << @defaults[:ascii_font] if @defaults[:ascii_font]
201
+
202
+ faces = []
203
+ fonts.each do |font|
204
+ next if font.nil? || font.empty?
205
+
206
+ face = FONT_FACE_LIBRARY[font]
207
+ if face
208
+ faces << face.strip
209
+ else
210
+ faces << generic_font_face(font)
211
+ end
212
+ end
213
+ "/* Font Definitions */\n#{faces.join("\n")}" unless faces.empty?
214
+ end
215
+
216
+ def collect_fonts_from_styles(styles, fonts)
217
+ styles.each_value do |style|
218
+ run_props = style["run_properties"] || {}
219
+ font_hash = run_props["fonts"] || {}
220
+ font_hash.each_value { |f| fonts << f if f }
221
+ end
222
+ end
223
+
224
+ def generic_font_face(font)
225
+ <<~CSS.strip
226
+ @font-face {
227
+ font-family: "#{font}";
228
+ mso-font-charset: 0;
229
+ mso-font-pitch: variable;
230
+ }
231
+ CSS
232
+ end
233
+
234
+ # Generate all style rules (paragraph, heading, character).
235
+ def generate_all_styles
236
+ parts = []
237
+
238
+ # Normal style
239
+ if @para_styles["Normal"]
240
+ parts << generate_mso_normal(@para_styles["Normal"])
241
+ end
242
+
243
+ # Headings h1-h6
244
+ (1..6).each do |n|
245
+ key = "Heading#{n}"
246
+ style = @para_styles[key]
247
+ parts << generate_heading(n, style, key) if style
248
+ end
249
+
250
+ # TOC styles
251
+ (1..9).each do |n|
252
+ key = "TOC#{n}"
253
+ style = @para_styles[key]
254
+ parts << generate_toc_style(n, style, key) if style
255
+ end
256
+
257
+ # ISO-specific paragraph styles
258
+ iso_para_styles = %w[
259
+ ForewordTitle IntroTitle BiblioTitle ANNEX
260
+ a2 a3 a4 a5 a6
261
+ Note Example Code Formula Definition Terms TermNum
262
+ FigureTitle AnnexFigureTitle Tabletitle AnnexTableTitle
263
+ Tablebody BodyText ForewordText Source List ListParagraph
264
+ zzSTDTitle zzContents zzCopyright Header Footer
265
+ NormalWeb BalloonText Caption
266
+ ]
267
+ iso_para_styles.each do |name|
268
+ style = @para_styles[name]
269
+ parts << generate_paragraph_style(name, style) if style
270
+ end
271
+
272
+ # Any remaining paragraph styles not yet generated
273
+ generated = Set.new(%w[Normal Heading1 Heading2 Heading3 Heading4 Heading5 Heading6
274
+ TOC1 TOC2 TOC3 TOC4 TOC5 TOC6 TOC7 TOC8 TOC9] + iso_para_styles)
275
+ @para_styles.each do |name, style|
276
+ next if generated.include?(name)
277
+
278
+ parts << generate_paragraph_style(name, style)
279
+ end
280
+
281
+ # Generic p style
282
+ parts << generate_generic_p
283
+
284
+ # Hyperlink styles
285
+ parts << generate_hyperlink_styles
286
+
287
+ # Character styles
288
+ @char_styles.each do |name, style|
289
+ parts << generate_character_style(name, style)
290
+ end
291
+
292
+ # MsoChpDefault
293
+ parts << generate_mso_chp_default
294
+
295
+ parts.compact.join("\n\n")
296
+ end
297
+
298
+ def generate_mso_normal(style)
299
+ props = build_css_properties(style)
300
+ <<~CSS
301
+ p.MsoNormal, li.MsoNormal, div.MsoNormal {
302
+ #{props}
303
+ }
304
+ CSS
305
+ end
306
+
307
+ def generate_heading(level, style, _key)
308
+ css_props = build_css_properties(style)
309
+ # Heading styles use h1-h6 tag selectors
310
+ <<~CSS
311
+ h#{level} {
312
+ #{css_props}
313
+ }
314
+ CSS
315
+ end
316
+
317
+ def generate_toc_style(level, style, _key)
318
+ css_props = build_css_properties(style)
319
+ <<~CSS
320
+ p.MsoToc#{level}, li.MsoToc#{level}, div.MsoToc#{level} {
321
+ #{css_props}
322
+ }
323
+ CSS
324
+ end
325
+
326
+ def generate_paragraph_style(name, style)
327
+ css_props = build_css_properties(style)
328
+ <<~CSS
329
+ p.#{name}, li.#{name}, div.#{name} {
330
+ #{css_props}
331
+ }
332
+ CSS
333
+ end
334
+
335
+ def generate_character_style(name, style)
336
+ css_props = build_char_css_properties(style)
337
+ return nil if css_props.strip.empty?
338
+
339
+ <<~CSS
340
+ span.#{name} {
341
+ #{css_props}
342
+ }
343
+ CSS
344
+ end
345
+
346
+ # Build CSS properties string for a paragraph style.
347
+ def build_css_properties(style)
348
+ lines = []
349
+ para_props = style["paragraph_properties"] || {}
350
+ run_props = style["run_properties"] || {}
351
+
352
+ # mso-style metadata
353
+ lines << " mso-style-unhide: no;"
354
+ if style["quick_format"]
355
+ lines << " mso-style-qformat: yes;"
356
+ end
357
+ if style["name"]
358
+ lines << " mso-style-name: \"#{style['name']}\";"
359
+ end
360
+ if style["based_on"]
361
+ lines << " mso-style-parent: \"#{style['based_on']}\";"
362
+ end
363
+ if style["linked_style"]
364
+ lines << " mso-style-link: \"#{style['linked_style']}\";"
365
+ end
366
+ if style["next_style"]
367
+ lines << " mso-style-next: #{style['next_style']};"
368
+ end
369
+ if style["ui_priority"]
370
+ lines << " mso-style-priority: #{style['ui_priority']};"
371
+ end
372
+ if style["semi_hidden"]
373
+ lines << " mso-style-noshow: yes;"
374
+ end
375
+
376
+ # Margins from spacing
377
+ spacing = para_props["spacing"] || {}
378
+ before = spacing["before"]
379
+ after = spacing["after"]
380
+ if before
381
+ lines << " margin-top: #{pt_value(before)};"
382
+ else
383
+ lines << " margin-top: 0cm;"
384
+ end
385
+ lines << " margin-right: 0cm;"
386
+ if after
387
+ lines << " margin-bottom: #{pt_value(after)};"
388
+ else
389
+ lines << " margin-bottom: #{pt_value(@normal_spacing_after)};"
390
+ end
391
+ lines << " margin-left: 0cm;"
392
+
393
+ # Indent
394
+ indent = para_props["indent"] || {}
395
+ if indent["left"] && indent["left"] != "0.0"
396
+ lines << " margin-left: #{cm_value(indent['left'])};"
397
+ end
398
+ if indent["right"] && indent["right"] != "0.0"
399
+ lines << " margin-right: #{cm_value(indent['right'])};"
400
+ end
401
+
402
+ # Text indent (firstLine or hanging)
403
+ if indent["hanging"] && indent["hanging"] != "0.0"
404
+ lines << " text-indent: -#{cm_value(indent['hanging'])};"
405
+ elsif indent["firstLine"] && indent["firstLine"] != "0.0"
406
+ lines << " text-indent: #{cm_value(indent['firstLine'])};"
407
+ else
408
+ lines << " text-indent: 0cm;" unless indent["hanging"]
409
+ end
410
+
411
+ # Alignment
412
+ alignment = para_props["alignment"]
413
+ align_map = { "both" => "justify", "left" => "left", "center" => "center",
414
+ "right" => "right", "distribute" => "justify" }
415
+ if alignment
416
+ lines << " text-align: #{align_map[alignment] || alignment};"
417
+ end
418
+
419
+ # Line height
420
+ line = spacing["line"]
421
+ if line
422
+ lines << " line-height: #{pt_value(line)};"
423
+ rule = spacing["line_rule"]
424
+ if rule == "exact"
425
+ lines << " mso-line-height-rule: exactly;"
426
+ end
427
+ end
428
+
429
+ # Pagination
430
+ lines << " mso-pagination: widow-orphan;"
431
+ if para_props["keep_next"]
432
+ lines << " page-break-after: avoid;"
433
+ end
434
+ if para_props["page_break_before"]
435
+ lines << " page-break-before: always;"
436
+ end
437
+
438
+ # Outline level
439
+ outline = para_props["outline_level"]
440
+ if outline
441
+ lines << " mso-outline-level: #{outline.to_i + 1};"
442
+ end
443
+
444
+ # Tab stops
445
+ tabs = para_props["tabs"]
446
+ if tabs && !tabs.empty?
447
+ tab_parts = tabs.map do |tab|
448
+ pos = cm_value(tab["position"])
449
+ case tab["type"]
450
+ when "left" then pos
451
+ when "right" then "right #{pos}"
452
+ when "center" then "center #{pos}"
453
+ when "clear" then "clear #{pos}"
454
+ when "num" then pos
455
+ else pos
456
+ end
457
+ end
458
+ lines << " tab-stops: #{tab_parts.join(' ')};"
459
+ end
460
+
461
+ # Borders
462
+ borders = para_props["borders"]
463
+ if borders && !borders.empty?
464
+ borders.each do |border|
465
+ side = border["type"]
466
+ val = border["val"]
467
+ sz = border["sz"]
468
+ space = border["space"]
469
+ color = border["color"]
470
+ border_str = "#{val}"
471
+ border_str += " ##{color}" if color
472
+ border_str += " #{pt_value(sz)}" if sz
473
+ lines << " border-#{side}: #{border_str};"
474
+ lines << " mso-border-#{side}-alt: solid ##{color} #{pt_value(sz)};" if color && sz
475
+ if space
476
+ padding_side = side == "top" || side == "bottom" ? "top" : "left"
477
+ lines << " padding: #{pt_value(space)};"
478
+ end
479
+ end
480
+ end
481
+
482
+ # Font size
483
+ font_size = run_props["font_size"]
484
+ if font_size
485
+ lines << " font-size: #{pt_value(font_size)};"
486
+ else
487
+ lines << " font-size: #{pt_value(@defaults[:font_size])};"
488
+ end
489
+
490
+ # Font family
491
+ fonts = run_props["fonts"] || {}
492
+ ascii = fonts["ascii"] || @defaults[:ascii_font]
493
+ if ascii
494
+ lines << " font-family: \"#{ascii}\", serif;"
495
+ end
496
+
497
+ # Bold/Italic
498
+ if run_props["bold"]
499
+ lines << " font-weight: bold;"
500
+ end
501
+ if run_props["italic"]
502
+ lines << " font-style: italic;"
503
+ end
504
+
505
+ # Underline
506
+ if run_props["underline"]
507
+ lines << " text-decoration: underline;"
508
+ end
509
+
510
+ # Color
511
+ color = run_props["color"]
512
+ if color
513
+ lines << " color: ##{color};"
514
+ end
515
+
516
+ # Font-specific mso properties
517
+ fareast = fonts["eastAsia"]
518
+ if fareast
519
+ lines << " mso-fareast-font-family: \"#{fareast}\", serif;"
520
+ elsif ascii == "Cambria"
521
+ lines << " mso-fareast-font-family: \"SimSun\", serif;"
522
+ end
523
+
524
+ bidi = fonts["cs"]
525
+ if bidi
526
+ lines << " mso-bidi-font-family: \"#{bidi}\", serif;"
527
+ elsif ascii
528
+ lines << " mso-bidi-font-family: \"#{ascii}\", serif;"
529
+ end
530
+
531
+ # Language
532
+ lang = run_props["lang_val"]
533
+ lines << " mso-ansi-language: #{lang || @defaults[:lang]};"
534
+
535
+ fareast_lang = run_props["lang_eastasia"]
536
+ if fareast_lang
537
+ lines << " mso-fareast-language: #{lang_code(fareast_lang)};"
538
+ end
539
+
540
+ lines.join("\n")
541
+ end
542
+
543
+ # Build CSS properties for character styles.
544
+ def build_char_css_properties(style)
545
+ lines = []
546
+ run_props = style["run_properties"] || {}
547
+
548
+ # mso-style metadata
549
+ lines << " mso-style-name: \"#{style['name']}\";" if style["name"]
550
+ if style["ui_priority"]
551
+ lines << " mso-style-priority: #{style['ui_priority']};"
552
+ end
553
+ if style["semi_hidden"]
554
+ lines << " mso-style-noshow: yes;"
555
+ end
556
+ if style["linked_style"]
557
+ lines << " mso-style-link: \"#{style['linked_style']}\";"
558
+ end
559
+
560
+ # Font size
561
+ font_size = run_props["font_size"]
562
+ if font_size
563
+ lines << " mso-ansi-font-size: #{pt_value(font_size)};"
564
+ lines << " mso-bidi-font-size: #{pt_value(font_size)};" if run_props["font_size_cs"]
565
+ end
566
+
567
+ # Font family
568
+ fonts = run_props["fonts"] || {}
569
+ ascii = fonts["ascii"]
570
+ if ascii
571
+ lines << " font-family: \"#{ascii}\", serif;"
572
+ lines << " mso-ascii-font-family: #{ascii};"
573
+ end
574
+
575
+ fareast = fonts["eastAsia"]
576
+ lines << " mso-fareast-font-family: \"#{fareast}\", serif;" if fareast
577
+
578
+ hansi = fonts["hAnsi"]
579
+ lines << " mso-hansi-font-family: \"#{hansi}\", serif;" if hansi
580
+
581
+ bidi = fonts["cs"]
582
+ lines << " mso-bidi-font-family: \"#{bidi}\";" if bidi
583
+
584
+ # Bold/Italic
585
+ lines << " font-weight: bold;" if run_props["bold"]
586
+ lines << " mso-bidi-font-weight: normal;" if run_props["bold"]
587
+ lines << " font-style: italic;" if run_props["italic"]
588
+
589
+ # Color
590
+ color = run_props["color"]
591
+ lines << " color: ##{color};" if color
592
+
593
+ # Underline
594
+ lines << " text-decoration: underline;" if run_props["underline"]
595
+
596
+ # Language
597
+ lang = run_props["lang_val"]
598
+ lines << " mso-ansi-language: #{lang || @defaults[:lang]};" if lang || @defaults[:lang]
599
+
600
+ lines.join("\n")
601
+ end
602
+
603
+ def generate_generic_p
604
+ <<~CSS
605
+ p {
606
+ mso-style-noshow: yes;
607
+ mso-style-priority: 99;
608
+ mso-margin-top-alt: auto;
609
+ margin-right: 0cm;
610
+ mso-margin-bottom-alt: auto;
611
+ mso-pagination: widow-orphan;
612
+ font-size: #{pt_value(@defaults[:font_size])};
613
+ font-family: "#{@defaults[:ascii_font]}", serif;
614
+ mso-ansi-language: #{@defaults[:lang]};
615
+ }
616
+ CSS
617
+ end
618
+
619
+ def generate_hyperlink_styles
620
+ <<~CSS
621
+ a:link, span.MsoHyperlink {
622
+ mso-style-priority: 99;
623
+ mso-style-unhide: no;
624
+ mso-style-parent: "";
625
+ color: blue;
626
+ text-decoration: underline;
627
+ text-underline: single;
628
+ }
629
+
630
+ a:visited, span.MsoHyperlinkFollowed {
631
+ mso-style-noshow: yes;
632
+ mso-style-priority: 99;
633
+ color: #954F72;
634
+ text-decoration: underline;
635
+ text-underline: single;
636
+ }
637
+ CSS
638
+ end
639
+
640
+ def generate_mso_chp_default
641
+ <<~CSS
642
+ .MsoChpDefault {
643
+ mso-style-type: export-only;
644
+ mso-default-props: yes;
645
+ font-family: "#{@defaults[:ascii_font]}", serif;
646
+ font-size: 10pt;
647
+ mso-ascii-font-family: "#{@defaults[:ascii_font]}", serif;
648
+ mso-fareast-font-family: "SimSun", serif;
649
+ mso-hansi-font-family: "#{@defaults[:ascii_font]}", serif;
650
+ }
651
+ CSS
652
+ end
653
+
654
+ def generate_mso_normal_table
655
+ <<~CSS
656
+ table.MsoNormalTable {
657
+ mso-style-name: "Table Normal";
658
+ mso-tstyle-rowband-size: 0;
659
+ mso-tstyle-colband-size: 0;
660
+ mso-style-noshow: yes;
661
+ mso-style-priority: 99;
662
+ mso-style-parent: "";
663
+ mso-padding-alt: 0cm 5.4pt 0cm 5.4pt;
664
+ mso-para-margin: 0cm;
665
+ mso-para-margin-bottom: 0.0001pt;
666
+ mso-pagination: widow-orphan;
667
+ font-size: 10pt;
668
+ font-family: "#{@defaults[:ascii_font]}", serif;
669
+ }
670
+ CSS
671
+ end
672
+
673
+ # Generate @page section definitions.
674
+ def generate_page_section
675
+ <<~CSS
676
+ /* Page Definitions */
677
+ @page {
678
+ mso-mirror-margins: yes;
679
+ mso-footnote-separator: url(cid:header.html) fs;
680
+ mso-footnote-continuation-separator: url(cid:header.html) fcs;
681
+ mso-endnote-separator: url(cid:header.html) es;
682
+ mso-endnote-continuation-separator: url(cid:header.html) ecs;
683
+ mso-facing-pages: yes;
684
+ }
685
+ @page WordSection1 {
686
+ size: 595.3pt 841.9pt;
687
+ margin: 39.7pt 36.85pt 14.2pt 42.55pt;
688
+ mso-header-margin: 35.45pt;
689
+ mso-footer-margin: 0cm;
690
+ mso-gutter-margin: 1cm;
691
+ mso-even-header: url(cid:header.html) eha;
692
+ mso-even-footer: url(cid:header.html) efa;
693
+ mso-footer: url(cid:header.html) fa;
694
+ mso-paper-source: 0;
695
+ }
696
+ div.WordSection1 {
697
+ page: WordSection1;
698
+ }
699
+ CSS
700
+ end
701
+
702
+ # Generate @list definitions from numbering.yml.
703
+ def generate_list_section
704
+ return "" unless @numbering
705
+
706
+ definitions = @numbering["definitions"] || []
707
+ instances = @numbering["instances"] || []
708
+
709
+ return "" if definitions.empty?
710
+
711
+ parts = []
712
+ definitions.each_with_index do |defn, idx|
713
+ parts << generate_list_definition(idx, defn)
714
+ end
715
+
716
+ parts.join("\n")
717
+ end
718
+
719
+ def generate_list_definition(idx, defn)
720
+ abstract_id = defn["abstract_num_id"]
721
+ levels = defn["levels"] || []
722
+ parts = ["@list l#{idx} {"]
723
+
724
+ # Build level CSS
725
+ levels.each do |level|
726
+ level_num = level["ilvl"].to_i
727
+ parts << " @list l#{idx}:level#{level_num + 1} {"
728
+
729
+ fmt = level["format"]
730
+ if fmt
731
+ fmt_map = {
732
+ "decimal" => nil, "bullet" => "mso-level-number-format: bullet;",
733
+ "upperLetter" => "mso-level-number-format: alpha-upper;",
734
+ "lowerLetter" => "mso-level-number-format: alpha-lower;",
735
+ "upperRoman" => "mso-level-number-format: roman-upper;",
736
+ "lowerRoman" => "mso-level-number-format: roman-lower;",
737
+ }
738
+ parts << " #{fmt_map[fmt]}" if fmt_map[fmt]
739
+ end
740
+
741
+ text = level["text"]
742
+ parts << " mso-level-text: \"#{text}\";" if text
743
+
744
+ para_props = level["paragraph_properties"] || {}
745
+ indent = para_props["indent"] || {}
746
+ left = indent["left"]
747
+ hanging = indent["hanging"]
748
+ if left
749
+ parts << " margin-left: #{cm_value(left)};"
750
+ end
751
+ if hanging
752
+ parts << " text-indent: -#{cm_value(hanging)};"
753
+ end
754
+
755
+ parts << " }"
756
+ end
757
+
758
+ parts << "}"
759
+ parts.join("\n")
760
+ end
761
+
762
+ def generate_list_containers
763
+ lines = (1..9).map do |n|
764
+ <<~CSS
765
+ div.ListContLevel#{n} {
766
+ mso-style-priority: 34;
767
+ margin-left: #{n * 18}pt;
768
+ margin-right: 0cm;
769
+ }
770
+ CSS
771
+ end
772
+ lines.join("\n")
773
+ end
774
+
775
+ def generate_ol_ul
776
+ <<~CSS
777
+ ol {
778
+ margin-bottom: 0cm;
779
+ margin-left: 18pt;
780
+ }
781
+
782
+ ul {
783
+ margin-bottom: 0cm;
784
+ margin-left: 18pt;
785
+ }
786
+ CSS
787
+ end
788
+
789
+ # Format a value as points (e.g., "11.0" → "11pt")
790
+ def pt_value(val)
791
+ return "0pt" unless val
792
+ v = val.to_f
793
+ v == v.to_i ? "#{v.to_i}pt" : "#{v}pt"
794
+ end
795
+
796
+ # Format a value as centimeters (e.g., "0.71" → "0.71cm")
797
+ def cm_value(val)
798
+ return "0cm" unless val
799
+ v = val.to_f
800
+ v == v.to_i ? "#{v.to_i}cm" : "#{v}cm"
801
+ end
802
+
803
+ # Convert language code: "ja-JP" → "JA", "en-GB" stays "EN-GB"
804
+ def lang_code(lang)
805
+ return lang unless lang
806
+
807
+ case lang
808
+ when "ja-JP" then "JA"
809
+ when "zh-CN" then "ZH-CN"
810
+ else lang.upcase
811
+ end
812
+ end
813
+ end
814
+ end
815
+ end