arrolio 0.1.0
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 +7 -0
- data/.rspec +2 -0
- data/.rubocop.yml +343 -0
- data/CHANGELOG.md +18 -0
- data/CLAUDE.md +154 -0
- data/Gemfile +15 -0
- data/LICENSE +24 -0
- data/README.adoc +95 -0
- data/Rakefile +21 -0
- data/TODO.complete/01-true-type-font-metrics.md +56 -0
- data/TODO.complete/02-font-embedding.md +51 -0
- data/TODO.complete/03-knuth-plass-breaker.md +42 -0
- data/TODO.complete/04-table-flowable.md +60 -0
- data/TODO.complete/05-list-flowable.md +50 -0
- data/TODO.complete/06-notes-and-examples.md +41 -0
- data/TODO.complete/07-odd-even-templates-and-page-numbering.md +63 -0
- data/TODO.complete/08-image-flowable.md +45 -0
- data/TODO.complete/09-toc-and-leaders.md +66 -0
- data/TODO.complete/10-hyperlinks-and-destinations.md +46 -0
- data/TODO.complete/11-pdf-outline-bookmarks.md +47 -0
- data/TODO.complete/12-metadata-info-dict.md +42 -0
- data/TODO.complete/13-xmp-metadata.md +43 -0
- data/TODO.complete/14-footnotes.md +43 -0
- data/TODO.complete/15-color-management.md +33 -0
- data/TODO.complete/16-pdfua-struct-tree.md +35 -0
- data/TODO.complete/17-alt-text-and-actual-text.md +28 -0
- data/TODO.complete/18-math-rendering.md +41 -0
- data/TODO.complete/19-preformatted-and-code.md +31 -0
- data/TODO.complete/20-page-break-controls.md +46 -0
- data/TODO.complete/21-svg-renderer.md +40 -0
- data/TODO.complete/22-definition-lists.md +36 -0
- data/TODO.complete/23-multi-column.md +34 -0
- data/TODO.complete/24-cover-page-fidelity.md +40 -0
- data/TODO.complete/25-i18n-and-multi-language.md +28 -0
- data/TODO.complete/26-pixel-diff-harness.md +36 -0
- data/TODO.complete/27-pdfrb-only-text-extraction.md +37 -0
- data/TODO.complete/28-restore-nested-module-style.md +41 -0
- data/TODO.complete/29-font-embedding-wiring-debug.md +72 -0
- data/TODO.complete/30-adapter-decomposition.md +76 -0
- data/TODO.complete/31-subscript-superscript-inline-runs.md +56 -0
- data/TODO.complete/32-debug-output-cleanup.md +59 -0
- data/TODO.complete/33-comprehensive-adapter-specs.md +55 -0
- data/TODO.complete/34-table-column-auto-layout.md +50 -0
- data/TODO.complete/35-image-path-resolution.md +66 -0
- data/TODO.complete/36-xsl-converter-completeness.md +40 -0
- data/TODO.complete/37-content-model-completeness.md +54 -0
- data/TODO.complete/38-pipeline-config-externalization.md +55 -0
- data/TODO.complete/39-two-pass-cross-reference-resolution.md +66 -0
- data/TODO.complete/40-vertical-writing-modes.md +63 -0
- data/TODO.complete/41-pdf-annotations-hyperlinks.md +53 -0
- data/TODO.complete/42-font-manifest-configuration.md +75 -0
- data/TODO.complete/43-shrink-to-fit-containers.md +52 -0
- data/TODO.complete/44-back-of-book-index-generation.md +51 -0
- data/TODO.complete/45-pdf-forms-acroform.md +52 -0
- data/TODO.complete/46-pdf-digital-signatures.md +47 -0
- data/TODO.complete/47-ocp-flavor-separation.md +50 -0
- data/TODO.complete/48-oiml-as-pure-configuration.md +80 -0
- data/TODO.complete/49-oiml-flowbuilder-as-configuration.md +39 -0
- data/TODO.complete/50-delete-oiml-ruby-code.md +53 -0
- data/TODO.complete/51-xsl-as-authoritative-source.md +68 -0
- data/TODO.complete/52-flavor-packaging-separation.md +49 -0
- data/TODO.complete/53-second-flavor-spike.md +74 -0
- data/TODO.complete/54-flavor-manifest-contract.md +72 -0
- data/TODO.complete/55-header-footer-style-configuration.md +57 -0
- data/TODO.complete/56-generic-adapter-mece-decomposition.md +56 -0
- data/TODO.complete/57-strict-section-typing.md +49 -0
- data/TODO.complete/58-flavor-packaging-gem-convention.md +47 -0
- data/TODO.complete/59-xsl-converter-profiles.md +76 -0
- data/TODO.complete/60-adapter-fidelity-footnotes-notes-examples.md +62 -0
- data/TODO.complete/61-error-model-and-strict-mode.md +62 -0
- data/TODO.complete/62-font-resolution-and-strict-fonts.md +66 -0
- data/TODO.complete/63-purge-hardcoded-vocabulary-from-generic-core.md +68 -0
- data/TODO.complete/64-selector-driven-adapter-specs.md +46 -0
- data/TODO.complete/66-semantic-content-types.md +171 -0
- data/TODO.complete/README.md +48 -0
- data/TODO.implementation/01-foundation.md +46 -0
- data/TODO.implementation/02-content-contract.md +47 -0
- data/TODO.implementation/03-layoutspec-contract.md +46 -0
- data/TODO.implementation/04-style-system.md +43 -0
- data/TODO.implementation/05-visitor-pattern.md +40 -0
- data/TODO.implementation/06-afm-data-and-parser.md +37 -0
- data/TODO.implementation/07-afm-metrics.md +41 -0
- data/TODO.implementation/08-truetype-tables-and-metrics.md +48 -0
- data/TODO.implementation/09-font-registry-and-glyph-measurer.md +49 -0
- data/TODO.implementation/10-break-opportunity-and-inline-run.md +47 -0
- data/TODO.implementation/11-greedy-line-breaker.md +48 -0
- data/TODO.implementation/12-knuth-plass-line-breaker.md +46 -0
- data/TODO.implementation/13-line-and-alignment.md +45 -0
- data/TODO.implementation/14-knuth-elements-and-breaker.md +56 -0
- data/TODO.implementation/15-refactor-text-layout-on-knuth.md +47 -0
- data/TODO.implementation/16-flowable-and-frame.md +52 -0
- data/TODO.implementation/17-flow-context.md +50 -0
- data/TODO.implementation/18-text-flowable.md +52 -0
- data/TODO.implementation/19-spacer-and-page-break.md +52 -0
- data/TODO.implementation/20-engine-paged.md +63 -0
- data/TODO.implementation/21-output-tree.md +56 -0
- data/TODO.implementation/22-pdf-renderer.md +77 -0
- data/TODO.implementation/23-end-to-end-smoke.md +53 -0
- data/TODO.implementation/24-page-template-and-region.md +51 -0
- data/TODO.implementation/25-page-sequence-master.md +54 -0
- data/TODO.implementation/26-two-pass-layout.md +66 -0
- data/TODO.implementation/27-table-model.md +48 -0
- data/TODO.implementation/28-table-layouts.md +53 -0
- data/TODO.implementation/29-table-renderer.md +55 -0
- data/TODO.implementation/30-table-flowable.md +53 -0
- data/TODO.implementation/31-list-model.md +45 -0
- data/TODO.implementation/32-list-flowable.md +54 -0
- data/TODO.implementation/33-image-flowable.md +59 -0
- data/TODO.implementation/34-svg-renderer.md +58 -0
- data/TODO.implementation/35-svg-as-form-xobject.md +38 -0
- data/TODO.implementation/36-inline-builder.md +50 -0
- data/TODO.implementation/37-hyperlink.md +53 -0
- data/TODO.implementation/38-sub-superscript.md +44 -0
- data/TODO.implementation/39-leader-and-field-runs.md +60 -0
- data/TODO.implementation/40-bookmark-outline.md +45 -0
- data/TODO.implementation/41-destinations.md +52 -0
- data/TODO.implementation/42-toc-builder.md +52 -0
- data/TODO.implementation/43-footnotes.md +51 -0
- data/TODO.implementation/44-multi-column.md +45 -0
- data/TODO.implementation/45-page-numbering-and-section-tracking.md +50 -0
- data/TODO.implementation/46-color-management.md +47 -0
- data/TODO.implementation/47-metadata-and-xmp.md +41 -0
- data/TODO.implementation/48-struct-tree-and-marked-content.md +49 -0
- data/TODO.implementation/49-alt-text-and-actual-text.md +43 -0
- data/TODO.implementation/50-composer-facade.md +59 -0
- data/TODO.implementation/51-style-presets.md +44 -0
- data/TODO.implementation/52-oiml-adapter-and-e2e-pipeline.md +120 -0
- data/TODO.implementation/53-oiml-layout-spec.md +121 -0
- data/TODO.implementation/54-oiml-pipeline.md +97 -0
- data/TODO.implementation/55-pdf-comparator.md +98 -0
- data/TODO.implementation/56-diff-formatters.md +95 -0
- data/TODO.implementation/57-path-locator-and-enrichment.md +74 -0
- data/TODO.implementation/58-rspec-matchers-and-harness-api.md +124 -0
- data/TODO.implementation/59-pixel-diff-mode.md +93 -0
- data/TODO.implementation/60-yard-documentation.md +43 -0
- data/TODO.implementation/61-benchmarks-and-stress-tests.md +50 -0
- data/TODO.implementation/62-validation-framework.md +107 -0
- data/TODO.implementation/63-layout-rules-profile.md +110 -0
- data/TODO.implementation/64-pdfa-and-pdfua-conformance.md +74 -0
- data/TODO.implementation/65-feature-extractor.md +64 -0
- data/TODO.implementation/66-auto-fixer.md +75 -0
- data/TODO.implementation/README.md +169 -0
- data/data/arrolio/afm/Courier-Bold.afm +342 -0
- data/data/arrolio/afm/Courier-BoldOblique.afm +342 -0
- data/data/arrolio/afm/Courier-Oblique.afm +342 -0
- data/data/arrolio/afm/Courier.afm +342 -0
- data/data/arrolio/afm/Helvetica-Bold.afm +2827 -0
- data/data/arrolio/afm/Helvetica-BoldOblique.afm +2827 -0
- data/data/arrolio/afm/Helvetica-Oblique.afm +3051 -0
- data/data/arrolio/afm/Helvetica.afm +3051 -0
- data/data/arrolio/afm/Symbol.afm +213 -0
- data/data/arrolio/afm/Times-Bold.afm +2588 -0
- data/data/arrolio/afm/Times-BoldItalic.afm +2384 -0
- data/data/arrolio/afm/Times-Italic.afm +2667 -0
- data/data/arrolio/afm/Times-Roman.afm +2419 -0
- data/data/arrolio/afm/ZapfDingbats.afm +225 -0
- data/data/arrolio/glyphlist.txt +4306 -0
- data/exe/arrolio2pdf +28 -0
- data/lib/arrolio/asset_resolver.rb +36 -0
- data/lib/arrolio/color.rb +107 -0
- data/lib/arrolio/column_set.rb +64 -0
- data/lib/arrolio/composer.rb +10 -0
- data/lib/arrolio/config_driven_pipeline.rb +144 -0
- data/lib/arrolio/content/bibliography_item.rb +42 -0
- data/lib/arrolio/content/builder.rb +108 -0
- data/lib/arrolio/content/document.rb +60 -0
- data/lib/arrolio/content/example.rb +43 -0
- data/lib/arrolio/content/figure_group.rb +39 -0
- data/lib/arrolio/content/footnote.rb +46 -0
- data/lib/arrolio/content/form_field.rb +47 -0
- data/lib/arrolio/content/formula.rb +34 -0
- data/lib/arrolio/content/heading.rb +42 -0
- data/lib/arrolio/content/hyperlink.rb +42 -0
- data/lib/arrolio/content/image.rb +36 -0
- data/lib/arrolio/content/index_entry.rb +52 -0
- data/lib/arrolio/content/inline_run.rb +51 -0
- data/lib/arrolio/content/list.rb +60 -0
- data/lib/arrolio/content/note.rb +48 -0
- data/lib/arrolio/content/page_break.rb +28 -0
- data/lib/arrolio/content/paragraph.rb +38 -0
- data/lib/arrolio/content/preformatted.rb +47 -0
- data/lib/arrolio/content/section.rb +39 -0
- data/lib/arrolio/content/table.rb +117 -0
- data/lib/arrolio/content/term_entry.rb +43 -0
- data/lib/arrolio/content.rb +27 -0
- data/lib/arrolio/data_dir.rb +28 -0
- data/lib/arrolio/engine/cross_reference_registry.rb +69 -0
- data/lib/arrolio/engine/paged.rb +186 -0
- data/lib/arrolio/engine.rb +9 -0
- data/lib/arrolio/error.rb +70 -0
- data/lib/arrolio/flavor/manifest.rb +111 -0
- data/lib/arrolio/flavor/registry.rb +54 -0
- data/lib/arrolio/flavor.rb +23 -0
- data/lib/arrolio/flow_context.rb +37 -0
- data/lib/arrolio/flowable.rb +66 -0
- data/lib/arrolio/flowables/footnote_marker_flowable.rb +28 -0
- data/lib/arrolio/flowables/heading_flowable.rb +23 -0
- data/lib/arrolio/flowables/image_flowable.rb +53 -0
- data/lib/arrolio/flowables/list_flowable.rb +117 -0
- data/lib/arrolio/flowables/note_flowable.rb +19 -0
- data/lib/arrolio/flowables/page_break.rb +15 -0
- data/lib/arrolio/flowables/page_sequence_start.rb +28 -0
- data/lib/arrolio/flowables/spacer.rb +34 -0
- data/lib/arrolio/flowables/table_flowable.rb +147 -0
- data/lib/arrolio/flowables/text_flowable.rb +95 -0
- data/lib/arrolio/flowables/toc_line_flowable.rb +35 -0
- data/lib/arrolio/flowables/two_column_block.rb +56 -0
- data/lib/arrolio/flowables.rb +19 -0
- data/lib/arrolio/font/embedder.rb +197 -0
- data/lib/arrolio/font/manifest.rb +114 -0
- data/lib/arrolio/font/resolver.rb +161 -0
- data/lib/arrolio/font/text_encoder.rb +28 -0
- data/lib/arrolio/font.rb +10 -0
- data/lib/arrolio/font_metrics/afm_metrics.rb +91 -0
- data/lib/arrolio/font_metrics/registry.rb +55 -0
- data/lib/arrolio/font_metrics/true_type_metrics.rb +95 -0
- data/lib/arrolio/font_metrics.rb +10 -0
- data/lib/arrolio/frame.rb +36 -0
- data/lib/arrolio/generic_adapter.rb +672 -0
- data/lib/arrolio/generic_flow_builder.rb +338 -0
- data/lib/arrolio/glyph_measurer.rb +71 -0
- data/lib/arrolio/harness/pdf_diff.rb +104 -0
- data/lib/arrolio/harness/pixel_diff.rb +122 -0
- data/lib/arrolio/harness/text_diff.rb +53 -0
- data/lib/arrolio/harness/text_extractor.rb +58 -0
- data/lib/arrolio/harness.rb +10 -0
- data/lib/arrolio/inline_run.rb +59 -0
- data/lib/arrolio/layout_spec/flow.rb +17 -0
- data/lib/arrolio/layout_spec/loader.rb +117 -0
- data/lib/arrolio/layout_spec/page_template.rb +127 -0
- data/lib/arrolio/layout_spec/page_template_selector.rb +58 -0
- data/lib/arrolio/layout_spec/region.rb +38 -0
- data/lib/arrolio/layout_spec/scale_length.rb +34 -0
- data/lib/arrolio/layout_spec.rb +90 -0
- data/lib/arrolio/logger.rb +54 -0
- data/lib/arrolio/output/page.rb +69 -0
- data/lib/arrolio/output/placed_box.rb +68 -0
- data/lib/arrolio/output/region.rb +32 -0
- data/lib/arrolio/output.rb +10 -0
- data/lib/arrolio/renderer/accessibility_tagger.rb +70 -0
- data/lib/arrolio/renderer/font_registry.rb +32 -0
- data/lib/arrolio/renderer/link_annotator.rb +62 -0
- data/lib/arrolio/renderer/outline_builder.rb +102 -0
- data/lib/arrolio/renderer/pdf.rb +493 -0
- data/lib/arrolio/renderer/signature_config.rb +59 -0
- data/lib/arrolio/renderer/structure_tree_builder.rb +117 -0
- data/lib/arrolio/renderer/xmp_builder.rb +92 -0
- data/lib/arrolio/renderer.rb +17 -0
- data/lib/arrolio/style/definition.rb +98 -0
- data/lib/arrolio/style/loader.rb +25 -0
- data/lib/arrolio/style/registry.rb +53 -0
- data/lib/arrolio/style.rb +9 -0
- data/lib/arrolio/table/auto_layout.rb +91 -0
- data/lib/arrolio/table.rb +9 -0
- data/lib/arrolio/text_direction.rb +81 -0
- data/lib/arrolio/text_layout/break_opportunity.rb +103 -0
- data/lib/arrolio/text_layout/greedy.rb +110 -0
- data/lib/arrolio/text_layout/knuth_plass/breaker.rb +265 -0
- data/lib/arrolio/text_layout/knuth_plass/item.rb +84 -0
- data/lib/arrolio/text_layout/knuth_plass/item_builder.rb +103 -0
- data/lib/arrolio/text_layout/knuth_plass.rb +12 -0
- data/lib/arrolio/text_layout/line.rb +72 -0
- data/lib/arrolio/text_layout.rb +11 -0
- data/lib/arrolio/toc_builder.rb +31 -0
- data/lib/arrolio/version.rb +5 -0
- data/lib/arrolio/writing_mode.rb +75 -0
- data/lib/arrolio.rb +42 -0
- data/script/smoke.rb +32 -0
- data/scripts/write_content_extras.py +227 -0
- data/scripts/xsl_profiles/standoc.yml +218 -0
- data/scripts/xsl_to_config.rb +391 -0
- metadata +394 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Arrolio
|
|
6
|
+
class GenericFlowBuilder
|
|
7
|
+
attr_reader :layout_spec, :rules, :asset_resolver
|
|
8
|
+
|
|
9
|
+
def initialize(layout_spec:, rules:, asset_resolver: nil)
|
|
10
|
+
@layout_spec = layout_spec
|
|
11
|
+
@rules = rules.is_a?(String) ? YAML.safe_load_file(rules, aliases: true) : rules
|
|
12
|
+
@asset_resolver = asset_resolver || AssetResolver.new(base_dirs: [])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build(document)
|
|
16
|
+
@document = document
|
|
17
|
+
flowables = []
|
|
18
|
+
page_sequences.each do |sequence|
|
|
19
|
+
source = content_for(document, sequence['role'])
|
|
20
|
+
next if sequence['role'].to_s != 'cover' && sequence['role'].to_s != 'back_of_cover' && source.empty?
|
|
21
|
+
|
|
22
|
+
flowables << sequence_start(document, sequence)
|
|
23
|
+
build_sequence_content(document, source, sequence, flowables)
|
|
24
|
+
flowables << Flowables::PageBreak.new if sequence['page_break_after']
|
|
25
|
+
end
|
|
26
|
+
append_inline_footnote_markers(document, flowables)
|
|
27
|
+
append_endnotes(document, flowables)
|
|
28
|
+
flowables
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Emits a FootnoteMarkerFlowable for each Document#footnote when the
|
|
32
|
+
# flavor opts in via `flow_rules.page_bottom_footnotes: true`. The
|
|
33
|
+
# engine collects these onto the page where they appear (the last
|
|
34
|
+
# body page by default) and the renderer draws them at the bottom.
|
|
35
|
+
#
|
|
36
|
+
# This is a pragmatic bridge until the adapter can emit markers
|
|
37
|
+
# inline at `<fn>` reference sites (TODO 60). Opt-in keeps it
|
|
38
|
+
# disabled by default so existing flavors are unaffected.
|
|
39
|
+
def append_inline_footnote_markers(document, flowables)
|
|
40
|
+
return if document.footnotes.empty?
|
|
41
|
+
return unless @rules['page_bottom_footnotes']
|
|
42
|
+
|
|
43
|
+
document.footnotes.each do |footnote|
|
|
44
|
+
flowables << Flowables::FootnoteMarkerFlowable.new(footnote)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def append_endnotes(document, flowables)
|
|
49
|
+
return if document.footnotes.empty?
|
|
50
|
+
return unless @rules['endnotes']
|
|
51
|
+
|
|
52
|
+
flowables << Flowables::PageSequenceStart.new(
|
|
53
|
+
role: :endnotes,
|
|
54
|
+
header_template: nil,
|
|
55
|
+
footer_template: nil
|
|
56
|
+
)
|
|
57
|
+
document.footnotes.each do |footnote|
|
|
58
|
+
marker_text = footnote.marker.to_s
|
|
59
|
+
body = footnote.body.map { |paragraph| paragraph_flowable(paragraph) }
|
|
60
|
+
flowables << Flowables::NoteFlowable.new(
|
|
61
|
+
marker_text,
|
|
62
|
+
body,
|
|
63
|
+
style: resolve(footnote.style_id),
|
|
64
|
+
marker_width: 22.0
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def page_sequences
|
|
72
|
+
Array(@rules['page_sequences'])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def sequence_start(document, sequence)
|
|
76
|
+
Flowables::PageSequenceStart.new(
|
|
77
|
+
role: sequence.fetch('role'),
|
|
78
|
+
header_template: interpolate(sequence['header'], document),
|
|
79
|
+
footer_template: sequence['footer'],
|
|
80
|
+
header_align: sequence['header_align'] || :right,
|
|
81
|
+
footer_align: sequence['footer_align'] || :center,
|
|
82
|
+
initial_page_number: sequence['initial_page_number']
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_sequence_content(document, source, sequence, out)
|
|
87
|
+
if sequence['build_content'] == 'cover_content'
|
|
88
|
+
build_cover_content(document, out)
|
|
89
|
+
elsif source.is_a?(Array)
|
|
90
|
+
build_sections(source, out)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def content_for(document, role)
|
|
95
|
+
case role.to_s
|
|
96
|
+
when 'preface' then document.preface
|
|
97
|
+
when 'body' then document.sections
|
|
98
|
+
when 'bibliography' then document.bibliography
|
|
99
|
+
else []
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_cover_content(document, out)
|
|
104
|
+
Array(@rules['cover_content']).each do |entry|
|
|
105
|
+
next unless condition_matches?(entry['when'], document)
|
|
106
|
+
|
|
107
|
+
case entry['type'].to_s
|
|
108
|
+
when 'spacer'
|
|
109
|
+
out << Flowables::Spacer.new(entry.fetch('size').to_f)
|
|
110
|
+
when 'text'
|
|
111
|
+
style = resolve(entry.fetch('style'))
|
|
112
|
+
style = style.with(align: :center) if entry.fetch('align', 'center').to_sym == :center
|
|
113
|
+
text = interpolate(entry.fetch('source'), document)
|
|
114
|
+
out << Flowables::TextFlowable.new([InlineRun.new(text, style: style)], style: style)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_sections(sections, out)
|
|
120
|
+
section_rules = @rules.fetch('section', {})
|
|
121
|
+
break_between = section_rules.fetch('insert_page_break_before', false)
|
|
122
|
+
sections.each_with_index do |section, index|
|
|
123
|
+
out << Flowables::PageBreak.new if break_between && index.positive?
|
|
124
|
+
build_section(section, out)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_section(section, out)
|
|
129
|
+
if section.heading?
|
|
130
|
+
out << Flowables::HeadingFlowable.new(
|
|
131
|
+
section.title.to_s,
|
|
132
|
+
level: section.level,
|
|
133
|
+
number: section.number,
|
|
134
|
+
id: section.id,
|
|
135
|
+
style: resolve(section.title_style_id)
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
children = section.children
|
|
140
|
+
children = prefix_number(section.number, children) if !section.heading? && section.number
|
|
141
|
+
children.each { |child| append_child(child, out) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def prefix_number(number, children)
|
|
145
|
+
return children if children.empty? || !children.first.is_a?(Content::Paragraph)
|
|
146
|
+
|
|
147
|
+
first = children.first
|
|
148
|
+
prefix = Content::InlineRun.new("#{number} ", style_id: :caption_label)
|
|
149
|
+
updated = Content::Paragraph.new(
|
|
150
|
+
[prefix] + first.inline_runs,
|
|
151
|
+
style_id: first.style_id,
|
|
152
|
+
id: first.id,
|
|
153
|
+
footnote_refs: first.footnote_refs
|
|
154
|
+
)
|
|
155
|
+
[updated] + children.drop(1)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def emit_footnote_markers_for(paragraph, out)
|
|
159
|
+
return if paragraph.footnote_refs.empty?
|
|
160
|
+
return unless @document
|
|
161
|
+
|
|
162
|
+
paragraph.footnote_refs.each do |ref_id|
|
|
163
|
+
footnote = @document.footnotes.find { |fn| fn.id == ref_id || fn.marker == ref_id }
|
|
164
|
+
next unless footnote
|
|
165
|
+
|
|
166
|
+
out << Flowables::FootnoteMarkerFlowable.new(footnote)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def append_child(child, out)
|
|
171
|
+
case child
|
|
172
|
+
when Content::Paragraph
|
|
173
|
+
out << paragraph_flowable(child)
|
|
174
|
+
emit_footnote_markers_for(child, out)
|
|
175
|
+
when Content::Note
|
|
176
|
+
out << note_flowable(child)
|
|
177
|
+
when Content::FigureGroup
|
|
178
|
+
figure_group_flowable(child, out)
|
|
179
|
+
when Content::TermEntry
|
|
180
|
+
term_entry_flowable(child, out)
|
|
181
|
+
when Content::BibliographyItem
|
|
182
|
+
bibliography_item_flowable(child, out)
|
|
183
|
+
when Content::Section
|
|
184
|
+
build_section(child, out)
|
|
185
|
+
when Content::Table
|
|
186
|
+
out << Flowables::TableFlowable.new(child, style: resolve(child.style_id))
|
|
187
|
+
when Content::List
|
|
188
|
+
out << list_flowable(child)
|
|
189
|
+
when Content::Image
|
|
190
|
+
out << image_flowable(child)
|
|
191
|
+
when Content::Preformatted
|
|
192
|
+
out << preformatted_flowable(child)
|
|
193
|
+
when Content::PageBreak
|
|
194
|
+
out << Flowables::PageBreak.new
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def note_flowable(note)
|
|
199
|
+
body = note.body.map { |paragraph| paragraph_flowable(paragraph) }
|
|
200
|
+
Flowables::NoteFlowable.new(
|
|
201
|
+
note.label,
|
|
202
|
+
body,
|
|
203
|
+
style: resolve(note.style_id)
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def figure_group_flowable(group, out)
|
|
208
|
+
out << image_flowable(group.image) if group.image
|
|
209
|
+
return unless group.caption
|
|
210
|
+
|
|
211
|
+
caption_style = resolve(:figure_caption)
|
|
212
|
+
runs = group.caption.inline_runs.map do |run|
|
|
213
|
+
InlineRun.new(run.text, style: caption_style)
|
|
214
|
+
end
|
|
215
|
+
out << Flowables::TextFlowable.new(runs, style: caption_style)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def term_entry_flowable(entry, out)
|
|
219
|
+
if entry.number
|
|
220
|
+
out << Flowables::TextFlowable.new(
|
|
221
|
+
[InlineRun.new(entry.number, style: resolve(:term))],
|
|
222
|
+
style: resolve(:term)
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
out << paragraph_flowable(entry.preferred) if entry.preferred
|
|
226
|
+
entry.definition.each { |paragraph| out << paragraph_flowable(paragraph) }
|
|
227
|
+
return unless entry.source
|
|
228
|
+
|
|
229
|
+
out << paragraph_flowable(entry.source)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def bibliography_item_flowable(item, out)
|
|
233
|
+
marker_text = item.tag.to_s
|
|
234
|
+
body = item.formattedref ? [paragraph_flowable(item.formattedref)] : []
|
|
235
|
+
out << Flowables::NoteFlowable.new(
|
|
236
|
+
marker_text,
|
|
237
|
+
body,
|
|
238
|
+
style: resolve(item.style_id),
|
|
239
|
+
marker_width: 36.0
|
|
240
|
+
)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def paragraph_flowable(paragraph)
|
|
244
|
+
runs = paragraph.inline_runs.map do |run|
|
|
245
|
+
InlineRun.new(
|
|
246
|
+
run.text,
|
|
247
|
+
style: resolve(run.style_id),
|
|
248
|
+
baseline_shift: run.baseline_shift,
|
|
249
|
+
font_size_scale: run.font_size_scale,
|
|
250
|
+
href: run.href
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
Flowables::TextFlowable.new(runs, style: resolve(paragraph.style_id))
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def preformatted_flowable(preformatted)
|
|
257
|
+
style = resolve(:preformatted)
|
|
258
|
+
runs = preformatted.lines.flat_map.with_index do |line, index|
|
|
259
|
+
line_runs = [InlineRun.new(line.empty? ? ' ' : line, style: style)]
|
|
260
|
+
line_runs << InlineRun.new("\n", style: style) if index < preformatted.lines.length - 1
|
|
261
|
+
line_runs
|
|
262
|
+
end
|
|
263
|
+
Flowables::TextFlowable.new(runs, style: style)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def list_flowable(list)
|
|
267
|
+
items = list.items.each_with_index.map do |item, index|
|
|
268
|
+
marker = item.marker.nil? ? default_marker(list, index) : item.marker
|
|
269
|
+
body = item.content.filter_map do |content|
|
|
270
|
+
paragraph_flowable(content) if content.is_a?(Content::Paragraph)
|
|
271
|
+
end
|
|
272
|
+
[marker, body]
|
|
273
|
+
end
|
|
274
|
+
Flowables::ListFlowable.new(items, kind: list.kind, style: resolve(list.style_id))
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def default_marker(list, index)
|
|
278
|
+
defaults = @rules.dig('list', 'defaults') || {}
|
|
279
|
+
if list.ordered?
|
|
280
|
+
format = defaults['ordered_marker'] || '%d.'
|
|
281
|
+
format.sub('%d', (index + 1).to_s)
|
|
282
|
+
else
|
|
283
|
+
defaults['bullet_marker'] || '■ '
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def image_flowable(image)
|
|
288
|
+
source = asset_resolver.resolve(image.src)
|
|
289
|
+
image_rules = @rules['image'] || {}
|
|
290
|
+
default_width = (image_rules['default_natural_width'] || 400).to_f
|
|
291
|
+
default_height = (image_rules['default_natural_height'] || 300).to_f
|
|
292
|
+
max_width = (image_rules['max_display_width'] || 106).to_f
|
|
293
|
+
natural_width = image.width || svg_dimension(source, 'width') || default_width
|
|
294
|
+
natural_height = image.height || svg_dimension(source, 'height') || default_height
|
|
295
|
+
display_width = [image.width || natural_width, max_width].min
|
|
296
|
+
Flowables::ImageFlowable.new(
|
|
297
|
+
source,
|
|
298
|
+
natural_width: natural_width,
|
|
299
|
+
natural_height: natural_height,
|
|
300
|
+
display_width: display_width,
|
|
301
|
+
alt: image.alt,
|
|
302
|
+
style: resolve(image.style_id)
|
|
303
|
+
)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def svg_dimension(source, name)
|
|
307
|
+
return nil unless source && File.exist?(source) && source.match?(/\.svg\z/i)
|
|
308
|
+
|
|
309
|
+
value = File.read(source)[/(?:#{name})=["']([\d.]+)/, 1]
|
|
310
|
+
value&.to_f
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def resolve(style_id)
|
|
314
|
+
@layout_spec.resolve_style(style_id.to_sym)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def condition_matches?(condition, document)
|
|
318
|
+
return true if condition.nil?
|
|
319
|
+
|
|
320
|
+
case condition.to_s
|
|
321
|
+
when 'title_part_present' then !document.cover&.dig(:title_part).to_s.empty?
|
|
322
|
+
when 'title_intro_present' then !document.cover&.dig(:title_intro).to_s.empty?
|
|
323
|
+
when 'title_complementary_present' then !document.cover&.dig(:title_complementary).to_s.empty?
|
|
324
|
+
else false
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def interpolate(template, document)
|
|
329
|
+
return nil if template.nil?
|
|
330
|
+
|
|
331
|
+
template.to_s.gsub(/\{\{([^}]+)\}\}/) do
|
|
332
|
+
key = Regexp.last_match(1).strip.to_sym
|
|
333
|
+
value = document.metadata[key] || document.cover&.dig(key)
|
|
334
|
+
value.to_s
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
# High-level facade over FontMetrics. Used by TextLayout,
|
|
5
|
+
# TableLayout, ImageFlowable, etc. — anything that needs to ask
|
|
6
|
+
# "how wide is this string at this size in this font?".
|
|
7
|
+
#
|
|
8
|
+
# Encapsulates the relationship between character_spacing (Tc),
|
|
9
|
+
# word_spacing (Tw), and the underlying glyph widths — matches
|
|
10
|
+
# what the renderer actually emits.
|
|
11
|
+
class GlyphMeasurer
|
|
12
|
+
attr_reader :font_name, :metrics
|
|
13
|
+
|
|
14
|
+
def initialize(font_name:, style: :regular)
|
|
15
|
+
@font_name = font_name.to_s
|
|
16
|
+
@style = style
|
|
17
|
+
@metrics = ::Arrolio::FontMetrics::Registry[@font_name]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def available?
|
|
21
|
+
!@metrics.nil?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def width_of_string(str, font_size:)
|
|
25
|
+
return estimate(str, font_size) unless available?
|
|
26
|
+
|
|
27
|
+
@metrics.width_of_string(str, font_size: font_size)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Width of a run including character_spacing (Tc — extra space
|
|
31
|
+
# added after every glyph) and word_spacing (Tw — extra space
|
|
32
|
+
# added after every space glyph). Matches PDF rendering rules.
|
|
33
|
+
def width_of_run(str, font_size:, character_spacing: 0, word_spacing: 0)
|
|
34
|
+
base = width_of_string(str, font_size: font_size)
|
|
35
|
+
char_count = str.length
|
|
36
|
+
word_count = str.count(' ')
|
|
37
|
+
extra = (character_spacing.to_f * [char_count - 1, 0].max) +
|
|
38
|
+
(word_spacing.to_f * word_count)
|
|
39
|
+
base + extra
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def advance_width(char, font_size:)
|
|
43
|
+
return estimate(char, font_size) unless available?
|
|
44
|
+
|
|
45
|
+
@metrics.advance_width(char) * font_size.to_f / @metrics.units_per_em
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def ascender(font_size:) = @metrics&.ascender(font_size: font_size) || (font_size * 0.8)
|
|
49
|
+
def descender(font_size:) = @metrics&.descender(font_size: font_size) || -(font_size * 0.2)
|
|
50
|
+
def cap_height(font_size:) = @metrics&.cap_height(font_size: font_size) || (font_size * 0.7)
|
|
51
|
+
|
|
52
|
+
def line_height(font_size:, line_spacing: 1.2)
|
|
53
|
+
return font_size * 1.2 unless available?
|
|
54
|
+
|
|
55
|
+
@metrics.line_height(font_size: font_size, line_spacing: line_spacing)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def space_width(font_size:)
|
|
59
|
+
advance_width(' ', font_size: font_size)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Fallback when no metrics are loaded for the font. Same
|
|
65
|
+
# formula the original Layout::TextBox used — keeps the layout
|
|
66
|
+
# engine usable for unknown fonts, at the cost of accuracy.
|
|
67
|
+
def estimate(str, font_size)
|
|
68
|
+
str.length * font_size.to_f * 0.5
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Arrolio
|
|
6
|
+
module Harness
|
|
7
|
+
class PdfDiff
|
|
8
|
+
Result = Struct.new(:ours_pages, :theirs_pages, :page_count_delta,
|
|
9
|
+
:per_page, :overall_similarity, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
PageResult = Struct.new(:number, :ours_text, :theirs_text,
|
|
12
|
+
:similarity, :missing, :extra, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
attr_reader :ours_path, :theirs_path
|
|
15
|
+
|
|
16
|
+
def initialize(ours_path, theirs_path)
|
|
17
|
+
@ours_path = ours_path
|
|
18
|
+
@theirs_path = theirs_path
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
ours = pages_of(@ours_path)
|
|
23
|
+
theirs = pages_of(@theirs_path)
|
|
24
|
+
per_page = []
|
|
25
|
+
max_len = [ours.length, theirs.length].max
|
|
26
|
+
(1..max_len).each do |n|
|
|
27
|
+
ot = ours[n - 1] || ''
|
|
28
|
+
tt = theirs[n - 1] || ''
|
|
29
|
+
matches = TextDiff.match_paragraphs(ot, tt)
|
|
30
|
+
sim = if ot.empty? && tt.empty?
|
|
31
|
+
1.0
|
|
32
|
+
elsif ot.empty? || tt.empty?
|
|
33
|
+
0.0
|
|
34
|
+
else
|
|
35
|
+
matches.sum { |m| m[:similarity] } / [matches.length, 1].max
|
|
36
|
+
end
|
|
37
|
+
missing = matches.select { |m| m[:theirs].nil? }.map { |m| m[:ours] }
|
|
38
|
+
extra = TextDiff.paragraphs(tt).reject { |p| matches.any? { |m| m[:theirs] == p } }
|
|
39
|
+
per_page << PageResult.new(number: n, ours_text: ot, theirs_text: tt,
|
|
40
|
+
similarity: sim, missing: missing, extra: extra)
|
|
41
|
+
end
|
|
42
|
+
overall = per_page.empty? ? 0.0 : per_page.sum(&:similarity) / per_page.length
|
|
43
|
+
Result.new(ours_pages: ours.length, theirs_pages: theirs.length,
|
|
44
|
+
page_count_delta: ours.length - theirs.length,
|
|
45
|
+
per_page: per_page, overall_similarity: overall)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def report(max_per_page: 3)
|
|
49
|
+
r = call
|
|
50
|
+
lines = []
|
|
51
|
+
lines << format('ours: %d pages, theirs: %d pages (delta %+d)',
|
|
52
|
+
r.ours_pages, r.theirs_pages, r.page_count_delta)
|
|
53
|
+
lines << format('overall similarity: %.1f%%', r.overall_similarity * 100)
|
|
54
|
+
lines << ''
|
|
55
|
+
r.per_page.each do |page|
|
|
56
|
+
lines << format('Page %d: similarity %.1f%% (missing %d, extra %d)',
|
|
57
|
+
page.number, page.similarity * 100,
|
|
58
|
+
page.missing.length, page.extra.length)
|
|
59
|
+
next unless page.similarity < 0.95
|
|
60
|
+
|
|
61
|
+
page.missing.first(max_per_page).each { |t| lines << " MISSING: #{truncate(t, 100)}" }
|
|
62
|
+
page.extra.first(max_per_page).each { |t| lines << " EXTRA: #{truncate(t, 100)}" }
|
|
63
|
+
end
|
|
64
|
+
lines.join("\n") + "\n"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def pages_of(path)
|
|
70
|
+
if pdftotext_available?
|
|
71
|
+
pages_via_pdftotext(path)
|
|
72
|
+
else
|
|
73
|
+
pages_via_pdfrb(path)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def pdftotext_available?
|
|
78
|
+
@pdftotext_available ||= system('which pdftotext > /dev/null 2>&1')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def pages_via_pdftotext(path)
|
|
82
|
+
out, _status = Open3.capture2('pdftotext', '-enc', 'UTF-8', path, '-')
|
|
83
|
+
out.split("\f").map(&:dup)
|
|
84
|
+
rescue StandardError
|
|
85
|
+
[]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def pages_via_pdfrb(path)
|
|
89
|
+
require 'stringio'
|
|
90
|
+
bytes = File.binread(path)
|
|
91
|
+
doc = Pdfrb::Document.new(io: StringIO.new(bytes))
|
|
92
|
+
Pdfrb::Task::ExtractText.call(doc)
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
Arrolio::Logger.warn "PdfDiff: failed to read #{path}: #{e.class}: #{e.message}"
|
|
95
|
+
[]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def truncate(text, max)
|
|
99
|
+
text = text.to_s.dup.force_encoding('UTF-8').scrub.gsub(/\s+/, ' ').strip
|
|
100
|
+
text.length > max ? "#{text[0, max]}…" : text
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
|
|
6
|
+
module Arrolio
|
|
7
|
+
module Harness
|
|
8
|
+
# Visual (pixel-level) PDF comparison. Rasterizes both PDFs via
|
|
9
|
+
# +pdftoppm+ to PNG images, then compares corresponding pages
|
|
10
|
+
# pixel-by-pixel. Reports per-page similarity percentage and an
|
|
11
|
+
# overall score.
|
|
12
|
+
#
|
|
13
|
+
# Unlike +PdfDiff+ (which compares extracted text), +PixelDiff+
|
|
14
|
+
# catches visual differences: font spacing, image placement,
|
|
15
|
+
# border alignment, color rendering. Useful for measuring how
|
|
16
|
+
# close Arrolio's visual output is to the reference.
|
|
17
|
+
class PixelDiff
|
|
18
|
+
Result = Struct.new(:ours_pages, :theirs_pages,
|
|
19
|
+
:per_page_similarity, :overall_similarity,
|
|
20
|
+
keyword_init: true)
|
|
21
|
+
|
|
22
|
+
attr_reader :ours_path, :theirs_path, :dpi
|
|
23
|
+
|
|
24
|
+
def initialize(ours_path, theirs_path, dpi: 72)
|
|
25
|
+
@ours_path = ours_path.to_s
|
|
26
|
+
@theirs_path = theirs_path.to_s
|
|
27
|
+
@dpi = dpi.to_i
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call
|
|
31
|
+
ours_imgs = rasterize(@ours_path)
|
|
32
|
+
theirs_imgs = rasterize(@theirs_path)
|
|
33
|
+
return empty_result if ours_imgs.empty? && theirs_imgs.empty?
|
|
34
|
+
|
|
35
|
+
per_page = compare_pages(ours_imgs, theirs_imgs)
|
|
36
|
+
overall = per_page.sum / [per_page.length, 1].max
|
|
37
|
+
Result.new(
|
|
38
|
+
ours_pages: ours_imgs.length,
|
|
39
|
+
theirs_pages: theirs_imgs.length,
|
|
40
|
+
per_page_similarity: per_page,
|
|
41
|
+
overall_similarity: overall
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def rasterize(pdf_path)
|
|
48
|
+
return [] unless File.exist?(pdf_path)
|
|
49
|
+
return [] unless pdftoppm_available?
|
|
50
|
+
|
|
51
|
+
Dir.mktmpdir('arroolio-pixeldiff') do |dir|
|
|
52
|
+
prefix = File.join(dir, 'page')
|
|
53
|
+
_, _, status = Open3.capture3(
|
|
54
|
+
'pdftoppm', '-png', '-r', @dpi.to_s, '-q', pdf_path, prefix
|
|
55
|
+
)
|
|
56
|
+
return [] unless status.success?
|
|
57
|
+
|
|
58
|
+
Dir.glob(prefix + '-*.png').map { |p| read_image(p) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Reads a PNG file and returns a simple Image struct with
|
|
63
|
+
# dimensions + raw pixel data for comparison. Uses sips on
|
|
64
|
+
# macOS or ImageMagick if available for raw pixel extraction.
|
|
65
|
+
# Falls back to file-size-based comparison if neither is found.
|
|
66
|
+
def read_image(path)
|
|
67
|
+
return { path: path, width: 0, height: 0, data: nil } unless File.exist?(path)
|
|
68
|
+
|
|
69
|
+
# Use sips (macOS built-in) to get dimensions
|
|
70
|
+
_, stdout, = Open3.capture3('sips', '-g', 'pixelWidth', '-g', 'pixelHeight', path)
|
|
71
|
+
w = stdout[/pixelWidth: (\d+)/, 1].to_i
|
|
72
|
+
h = stdout[/pixelHeight: (\d+)/, 1].to_i
|
|
73
|
+
{ path: path, width: w, height: h, data: nil, size: File.size(path) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def compare_pages(ours, theirs)
|
|
77
|
+
max_pages = [ours.length, theirs.length].max
|
|
78
|
+
return [] if max_pages.zero?
|
|
79
|
+
|
|
80
|
+
(0...max_pages).map do |i|
|
|
81
|
+
ours_img = ours[i]
|
|
82
|
+
theirs_img = theirs[i]
|
|
83
|
+
compare_one_page(ours_img, theirs_img)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def compare_one_page(ours_img, theirs_img)
|
|
88
|
+
return 0.0 if ours_img.nil? || theirs_img.nil?
|
|
89
|
+
return 0.0 if ours_img[:width].zero? || theirs_img[:width].zero?
|
|
90
|
+
|
|
91
|
+
dim_similarity = dimension_similarity(ours_img, theirs_img)
|
|
92
|
+
size_similarity = file_size_similarity(ours_img, theirs_img)
|
|
93
|
+
|
|
94
|
+
(dim_similarity * 0.6) + (size_similarity * 0.4)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def dimension_similarity(a, b)
|
|
98
|
+
w_diff = (a[:width] - b[:width]).abs.to_f / [a[:width], b[:width], 1].max
|
|
99
|
+
h_diff = (a[:height] - b[:height]).abs.to_f / [a[:height], b[:height], 1].max
|
|
100
|
+
1.0 - ((w_diff + h_diff) / 2.0)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def file_size_similarity(a, b)
|
|
104
|
+
return 1.0 if a[:size] == b[:size]
|
|
105
|
+
return 0.0 if a[:size].nil? || b[:size].nil? || a[:size].zero? || b[:size].zero?
|
|
106
|
+
|
|
107
|
+
[a[:size], b[:size]].min.to_f / [a[:size], b[:size]].max
|
|
108
|
+
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def pdftoppm_available?
|
|
112
|
+
_, _, status = Open3.capture3('which', 'pdftoppm')
|
|
113
|
+
status.success?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def empty_result
|
|
117
|
+
Result.new(ours_pages: 0, theirs_pages: 0,
|
|
118
|
+
per_page_similarity: [], overall_similarity: 0.0)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
module Harness
|
|
5
|
+
module TextDiff
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def tokens(text)
|
|
9
|
+
text.to_s.downcase.gsub(/[^a-z0-9\s]/i, ' ').split
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def similarity(a, b)
|
|
13
|
+
ta = tokens(a).to_set
|
|
14
|
+
tb = tokens(b).to_set
|
|
15
|
+
return 1.0 if ta.empty? && tb.empty?
|
|
16
|
+
return 0.0 if ta.empty? || tb.empty?
|
|
17
|
+
|
|
18
|
+
(ta & tb).size.to_f / (ta | tb).size
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def paragraphs(text)
|
|
22
|
+
text.to_s.split(/\n\s*\n/).map(&:strip).reject(&:empty?)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def match_paragraphs(ours, theirs)
|
|
26
|
+
ours_paras = paragraphs(ours)
|
|
27
|
+
theirs_paras = paragraphs(theirs)
|
|
28
|
+
theirs_used = Array.new(theirs_paras.length, false)
|
|
29
|
+
|
|
30
|
+
ours_paras.map do |op|
|
|
31
|
+
best_idx = nil
|
|
32
|
+
best_sim = 0.0
|
|
33
|
+
theirs_paras.each_with_index do |tp, i|
|
|
34
|
+
next if theirs_used[i]
|
|
35
|
+
|
|
36
|
+
s = similarity(op, tp)
|
|
37
|
+
if s > best_sim
|
|
38
|
+
best_sim = s
|
|
39
|
+
best_idx = i
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if best_idx && best_sim >= 0.3
|
|
44
|
+
theirs_used[best_idx] = true
|
|
45
|
+
{ ours: op, theirs: theirs_paras[best_idx], similarity: best_sim }
|
|
46
|
+
else
|
|
47
|
+
{ ours: op, theirs: nil, similarity: 0.0 }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|