uniword 1.0.10 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a7feae85851d80946524005c3cc6419419d492c717e559628093494ee7532cd9
4
- data.tar.gz: eba8d3d3d433aa158b5a4214659e6afd0e12d74e3fada13997da066021a78f9d
3
+ metadata.gz: bdd8529445dab8a8008addcef94857f4dc453cea19ece485baaac0ef1c4701f2
4
+ data.tar.gz: cbba0e318b8fde04e96cf0717f82e1a4a827ac4c73e47b8c4af853ba4dbd77be
5
5
  SHA512:
6
- metadata.gz: 940e11bb305505cd3e6acdc6e9cca98e002d151a0f75e7c8872d613353b4c5d85b424062a661572b14474c342b62db3072d2cc081cb9e924f95bb8fdf80e6c39
7
- data.tar.gz: 3ca3c9bea8133f7a18e64897b687ef8dd10843c85aa2e4422dabceed5152ae5a5f9cce56fdff8999f03e734f8510b919732f9f5b5b0d5ac0e9470c8dd0db11fb
6
+ metadata.gz: 92dc72fbc93219b74f58bb901aca4d1bda56786c761a04ceb724ffad3417406a13e92f962808613d495f8f68a0955278a9f9a2fcfef29848f6d803f88beab907
7
+ data.tar.gz: 59f335ab79d4cb3b72c80c9b07ffecaf3705285017ab494490c1d6f331fa310205a50ccc2b1f3bb2397c313cb392ee33fa99aec477edaac4cba0b11b4fed3f0f
@@ -188,6 +188,7 @@ module Uniword
188
188
  custom_xml_items: :custom_xml_items,
189
189
  footnotes: :footnotes,
190
190
  endnotes: :endnotes,
191
+ embeddings: :embeddings,
191
192
  }.freeze
192
193
 
193
194
  # Copy package parts to document for round-trip preservation
@@ -50,6 +50,7 @@ module Uniword
50
50
  package.chart_parts = document.chart_parts if document.chart_parts
51
51
  package.custom_xml_items = document.custom_xml_items if document.custom_xml_items
52
52
  package.bibliography_sources = document.bibliography_sources if document.bibliography_sources
53
+ package.embeddings = document.embeddings if document.embeddings
53
54
  end
54
55
 
55
56
  # Create minimal content types for a valid DOCX
@@ -17,6 +17,16 @@ module Uniword
17
17
  class Reconciler
18
18
  CONFIG_DIR = File.join(__dir__, "../../../config")
19
19
 
20
+ # All w1x/w16x extension namespace prefixes declared by namespace_scope
21
+ # on wordprocessingml parts. These MUST be listed in mc:Ignorable so
22
+ # Word doesn't reject them as unknown extensions.
23
+ IGNORABLE_PREFIXES = %w[
24
+ w14 w15 w16se w16cid w16 w16cex w16sdtdh w16sdtfl w16du
25
+ ].freeze
26
+
27
+ # Extension prefixes used in document.xml (adds wp14 for drawing)
28
+ IGNORABLE_PREFIXES_DOCUMENT = (IGNORABLE_PREFIXES + %w[wp14]).freeze
29
+
20
30
  def initialize(package, profile: nil)
21
31
  @package = package
22
32
  @profile = profile
@@ -507,11 +517,16 @@ module Uniword
507
517
 
508
518
  rsid = generate_rsid
509
519
 
520
+ # Only inject settings defaults for newly created documents.
521
+ # If settings already has substantial content (zoom + compat + rsids),
522
+ # the document was loaded from a real DOCX and defaults would alter
523
+ # its round-trip fidelity.
524
+ loaded_doc = settings.zoom && settings.compat && settings.rsids
525
+
510
526
  settings.zoom ||= Wordprocessingml::Zoom.new(percent: 100)
511
- settings.do_not_display_page_boundaries ||= Wordprocessingml::DoNotDisplayPageBoundaries.new
512
527
  settings.proof_state ||= Wordprocessingml::ProofState.new(
513
528
  spelling: "clean", grammar: "clean",
514
- )
529
+ ) unless loaded_doc
515
530
  settings.default_tab_stop ||= Wordprocessingml::DefaultTabStop.new(val: "720")
516
531
  settings.character_spacing_control ||= Wordprocessingml::CharacterSpacingControl.new(
517
532
  val: "doNotCompress",
@@ -545,7 +560,9 @@ module Uniword
545
560
  record_fix("R2", "Generated w15:docId in GUID format")
546
561
  end
547
562
 
548
- settings.mc_ignorable = nil
563
+ settings.mc_ignorable = Ooxml::Types::McIgnorable.new(
564
+ IGNORABLE_PREFIXES.join(" "),
565
+ )
549
566
  end
550
567
 
551
568
  def reconcile_font_table
@@ -586,7 +603,9 @@ module Uniword
586
603
  font_table.fonts << font
587
604
  end
588
605
 
589
- font_table.mc_ignorable = nil
606
+ font_table.mc_ignorable = Ooxml::Types::McIgnorable.new(
607
+ IGNORABLE_PREFIXES.join(" "),
608
+ )
590
609
  record_fix("R13",
591
610
  "Populated font table with profile fonts and signatures")
592
611
  end
@@ -605,7 +624,9 @@ module Uniword
605
624
 
606
625
  ensure_default_styles(styles)
607
626
 
608
- styles.mc_ignorable = nil
627
+ styles.mc_ignorable = Ooxml::Types::McIgnorable.new(
628
+ IGNORABLE_PREFIXES.join(" "),
629
+ )
609
630
  record_fix("R10",
610
631
  "Ensured styles have docDefaults, latentStyles, and default styles")
611
632
  end
@@ -614,7 +635,9 @@ module Uniword
614
635
  return unless profile
615
636
  return unless package.numbering
616
637
 
617
- package.numbering.mc_ignorable = nil
638
+ package.numbering.mc_ignorable = Ooxml::Types::McIgnorable.new(
639
+ IGNORABLE_PREFIXES.join(" "),
640
+ )
618
641
 
619
642
  # Validate instance → definition references
620
643
  package.numbering.instances.each do |inst|
@@ -640,8 +663,11 @@ module Uniword
640
663
  package.web_settings
641
664
  end
642
665
 
643
- ws.mc_ignorable = nil
644
- record_fix("R1", "Cleared mc:Ignorable on webSettings")
666
+ ws.mc_ignorable = Ooxml::Types::McIgnorable.new(
667
+ IGNORABLE_PREFIXES.join(" "),
668
+ )
669
+ ws.allow_png ||= Wordprocessingml::AllowPng.new
670
+ record_fix("R1", "Set mc:Ignorable and allowPNG on webSettings")
645
671
  end
646
672
 
647
673
  def reconcile_app_properties
@@ -735,7 +761,9 @@ module Uniword
735
761
  return unless package.document&.body
736
762
 
737
763
  doc = package.document
738
- doc.mc_ignorable = Ooxml::Types::McIgnorable.new("w14")
764
+ doc.mc_ignorable = Ooxml::Types::McIgnorable.new(
765
+ IGNORABLE_PREFIXES_DOCUMENT.join(" "),
766
+ )
739
767
 
740
768
  record_fix("R1", "Added mc:Ignorable to document body")
741
769
  record_fix("R12", "Assigned rsid and paraId to paragraphs")
@@ -697,6 +697,12 @@ module Uniword
697
697
  </style>
698
698
  LSB_STYLE
699
699
 
700
+ class << self
701
+ # Custom CSS override set by callers (e.g., ISO adapter).
702
+ # When set, #style_block returns this instead of the default.
703
+ attr_accessor :custom_style_block
704
+ end
705
+
700
706
  # Build the w:LatentStyles block.
701
707
  # @return [String] Static latent styles XML
702
708
  def self.latent_styles
@@ -704,9 +710,10 @@ module Uniword
704
710
  end
705
711
 
706
712
  # Build the CSS style block.
707
- # @return [String] Static CSS style block
713
+ # Returns custom CSS if set via custom_style_block=, otherwise default.
714
+ # @return [String] CSS style block
708
715
  def self.style_block
709
- STYLE_BLOCK
716
+ custom_style_block || STYLE_BLOCK
710
717
  end
711
718
  end
712
719
  end
@@ -18,7 +18,8 @@ module Uniword
18
18
  # output = Uniword::Infrastructure::MimePackager.new(mhtml_doc).build_mime_content
19
19
  #
20
20
  class OoxmlToMhtmlConverter
21
- # Static MsoNormalTable CSS (used in wrap_html_document head)
21
+ # Static MsoNormalTable CSS (used in wrap_html_document head).
22
+ # Only used when MhtmlStyleBuilder does not provide custom CSS.
22
23
  MSO_NORMAL_TABLE_STYLE = <<~CSS
23
24
  <!--[if gte mso 10]>
24
25
  <style>
@@ -30,21 +31,12 @@ module Uniword
30
31
  mso-style-noshow:yes;
31
32
  mso-style-priority:99;
32
33
  mso-style-parent:"";
33
- mso-padding-alt:0in 5.4pt 0in 5.4pt;
34
- mso-para-margin-top:0in;
35
- mso-para-margin-right:0in;
36
- mso-para-margin-bottom:8.0pt;
37
- mso-para-margin-left:0in;
38
- line-height:115%;
34
+ mso-padding-alt:0cm 5.4pt 0cm 5.4pt;
35
+ mso-para-margin:0cm;
36
+ mso-para-margin-bottom:.0001pt;
39
37
  mso-pagination:widow-orphan;
40
- font-size:12.0pt;
41
- font-family:"Aptos",sans-serif;
42
- mso-ascii-font-family:Aptos;
43
- mso-ascii-theme-font:minor-latin;
44
- mso-hansi-font-family:Aptos;
45
- mso-hansi-theme-font:minor-latin;
46
- mso-font-kerning:1.0pt;
47
- mso-ligatures:standardcontextual;}
38
+ font-size:10pt;
39
+ font-family:"Cambria",serif;}
48
40
  </style>
49
41
  <![endif]-->
50
42
  CSS
@@ -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
@@ -34,5 +34,7 @@ module Uniword
34
34
  "#{__dir__}/transformation/mhtml_element_renderer"
35
35
  autoload :MhtmlMetadataBuilder,
36
36
  "#{__dir__}/transformation/mhtml_metadata_builder"
37
+ autoload :YamlCssGenerator,
38
+ "#{__dir__}/transformation/yaml_css_generator"
37
39
  end
38
40
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Uniword
4
- VERSION = "1.0.10"
4
+ VERSION = "1.0.11"
5
5
  end
@@ -75,6 +75,18 @@ module Uniword
75
75
  h[e.name] += 1
76
76
  end
77
77
 
78
+ needed = false
79
+ needed = true if paragraphs.size > counts["p"]
80
+ needed = true if tables.size > counts["tbl"]
81
+ needed = true if structured_document_tags.size > counts["sdt"]
82
+ needed = true if bookmark_starts.size > counts["bookmarkStart"]
83
+ needed = true if bookmark_ends.size > counts["bookmarkEnd"]
84
+ needed = true if section_properties && counts["sectPr"].zero?
85
+
86
+ return unless needed
87
+
88
+ dup_element_order_if_frozen
89
+
78
90
  append_missing_elements("p", paragraphs.size - counts["p"])
79
91
  append_missing_elements("tbl", tables.size - counts["tbl"])
80
92
  append_missing_elements("sdt",
@@ -89,6 +101,12 @@ module Uniword
89
101
  element_order << build_order_element("sectPr")
90
102
  end
91
103
 
104
+ def dup_element_order_if_frozen
105
+ return unless element_order.frozen?
106
+
107
+ self.element_order = element_order.dup
108
+ end
109
+
92
110
  def append_missing_elements(name, count)
93
111
  count.times { element_order << build_order_element(name) }
94
112
  end
@@ -112,6 +112,8 @@ module Uniword
112
112
  attr_accessor :chart_parts
113
113
  # Bibliography sources for sources.xml
114
114
  attr_accessor :bibliography_sources
115
+ # OLE/embedded object binaries (word/embeddings/*)
116
+ attr_accessor :embeddings
115
117
  # Round-trip parts (copied from DocxPackage during load)
116
118
  attr_accessor :settings, :font_table, :web_settings, :document_rels, :theme_rels,
117
119
  :package_rels, :content_types, :custom_properties, :custom_xml_items
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: uniword
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.10
4
+ version: 1.0.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -1513,6 +1513,7 @@ files:
1513
1513
  - lib/uniword/transformation/transformation_rule.rb
1514
1514
  - lib/uniword/transformation/transformation_rule_registry.rb
1515
1515
  - lib/uniword/transformation/transformer.rb
1516
+ - lib/uniword/transformation/yaml_css_generator.rb
1516
1517
  - lib/uniword/validation.rb
1517
1518
  - lib/uniword/validation/checkers.rb
1518
1519
  - lib/uniword/validation/checkers/external_link_checker.rb