-
Notifications
You must be signed in to change notification settings - Fork 498
/
converter.rb
4663 lines (4394 loc) · 216 KB
/
converter.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# frozen_string_literal: true
require_relative 'formatted_text'
require_relative 'index_catalog'
require_relative 'pdfmark'
require_relative 'roman_numeral'
require_relative 'section_info_by_page'
autoload :StringIO, 'stringio'
autoload :Tempfile, 'tempfile'
module Asciidoctor
module PDF
class Converter < ::Prawn::Document
include ::Asciidoctor::Converter
include ::Asciidoctor::Logging
include ::Asciidoctor::Writer
include ::Asciidoctor::Prawn::Extensions
register_for 'pdf'
attr_reader :allow_uri_read
attr_reader :cache_uri
attr_accessor :font_color
attr_accessor :font_scale
attr_reader :root_font_size
attr_reader :index
attr_reader :theme
attr_reader :text_decoration_width
# NOTE: require_library doesn't support require_relative and we don't modify the load path for this gem
CodeRayRequirePath = ::File.join __dir__, 'ext/prawn/coderay_encoder'
RougeRequirePath = ::File.join __dir__, 'ext/rouge'
PygmentsRequirePath = ::File.join __dir__, 'ext/pygments'
OptimizerRequirePath = ::File.join __dir__, 'optimizer'
DecimalWithLeadingZero = ::Module.new
AdmonitionIcons = {
caution: { name: 'fas-fire', stroke_color: 'BF3400', size: 24 },
important: { name: 'fas-exclamation-circle', stroke_color: 'BF0000', size: 24 },
note: { name: 'fas-info-circle', stroke_color: '19407C', size: 24 },
tip: { name: 'far-lightbulb', stroke_color: '111111', size: 24 },
warning: { name: 'fas-exclamation-triangle', stroke_color: 'BF6900', size: 24 },
}
TextAlignmentNames = %w(justify left center right)
TextAlignmentRoles = %w(text-justify text-left text-center text-right)
TextDecorationStyleTable = { 'underline' => :underline, 'line-through' => :strikethrough }
FontKerningTable = { 'normal' => true, 'none' => false }
BlockAlignmentNames = %w(left center right)
(AlignmentTable = { '<' => :left, '=' => :center, '>' => :right }).default = :left
ColumnPositions = [:left, :center, :right]
PageLayouts = [:portrait, :landscape]
(PageModes = {
'fullscreen' => [:FullScreen, :UseOutlines],
'fullscreen none' => [:FullScreen, :UseNone],
'fullscreen outline' => [:FullScreen, :UseOutlines],
'fullscreen thumbs' => [:FullScreen, :UseThumbs],
'none' => :UseNone,
'outline' => :UseOutlines,
'thumbs' => :UseThumbs,
}).default = :UseOutlines
PageSides = [:recto, :verso]
(PDFVersions = { '1.3' => 1.3, '1.4' => 1.4, '1.5' => 1.5, '1.6' => 1.6, '1.7' => 1.7 }).default = 1.4
AuthorAttributeNames = %w(author authorinitials firstname middlename lastname email)
LF = ?\n
DoubleLF = LF * 2
TAB = ?\t
InnerIndent = LF + ' '
# a no-break space is used to replace a leading space to prevent Prawn from trimming indentation
# a leading zero-width space can't be used as it gets dropped when calculating the line width
GuardedIndent = ?\u00a0
GuardedInnerIndent = LF + GuardedIndent
TabRx = /\t/
TabIndentRx = /^\t+/
NoBreakSpace = ?\u00a0
ZeroWidthSpace = ?\u200b
DummyText = ?\u0000
DotLeaderTextDefault = '. '
EmDash = ?\u2014
RightPointer = ?\u25ba
LowercaseGreekA = ?\u03b1
Bullets = {
disc: ?\u2022,
circle: ?\u25e6,
square: ?\u25aa,
none: '',
}
# NOTE: default theme font uses ballot boxes from FontAwesome
BallotBox = {
checked: ?\u2611,
unchecked: ?\u2610,
}
ConumSets = {
'circled' => (?\u2460..?\u2473).to_a,
'filled' => (?\u2776..?\u277f).to_a + (?\u24eb..?\u24f4).to_a,
}
SimpleAttributeRefRx = /(?<!\\)\{\w+(?:-\w+)*\}/
MeasurementRxt = '\\d+(?:\\.\\d+)?(?:in|cm|mm|p[txc])?'
MeasurementPartsRx = /^(\d+(?:\.\d+)?)(in|mm|cm|p[txc])?$/
PageSizeRx = /^(?:\[(#{MeasurementRxt}), ?(#{MeasurementRxt})\]|(#{MeasurementRxt})(?: x |x)(#{MeasurementRxt})|\S+)$/
CalloutExtractRx = /(?:(?:\/\/|#|--|;;) ?)?(\\)?<!?(|--)(\d+|\.)\2> ?(?=(?:\\?<!?\2(?:\d+|\.)\2>)*$)/
ImageAttributeValueRx = /^image:{1,2}(.*?)\[(.*?)\]$/
StopPunctRx = /[.!?;:]$/
UriBreakCharsRx = /(?:\/|\?|&|#)(?!$)/
UriBreakCharRepl = %(\\&#{ZeroWidthSpace})
UriSchemeBoundaryRx = /(?<=:\/\/)/
LineScanRx = /\n|.+/
BlankLineRx = /\n{2,}/
CjkLineBreakRx = /(?=[\u3000\u30a0-\u30ff\u3040-\u309f\p{Han}\uff00-\uffef])/
WhitespaceChars = ' ' + TAB + LF
ValueSeparatorRx = /;|,/
HexColorRx = /^#[a-fA-F0-9]{6}$/
VimeoThumbnailRx = /<thumbnail_url>(.*?)<\/thumbnail_url>/
DropAnchorRx = /<(?:a\b[^>]*|\/a)>/
SourceHighlighters = %w(coderay pygments rouge).to_set
ViewportWidth = ::Module.new
(TitleStyles = {
'toc' => [:numbered_title],
'basic' => [:title],
}).default = [:numbered_title, formal: true]
def initialize backend, opts
super
basebackend 'html'
filetype 'pdf'
htmlsyntax 'html'
outfilesuffix '.pdf'
if (doc = opts[:document])
# NOTE: enabling data-uri forces Asciidoctor Diagram to produce absolute image paths
doc.attributes['data-uri'] = (doc.instance_variable_get :@attribute_overrides)['data-uri'] = ''
end
@initial_instance_variables = [:@initial_instance_variables] + instance_variables
end
def convert node, name = nil, _opts = {}
method_name = %(convert_#{name ||= node.node_name})
if respond_to? method_name
result = send method_name, node
else
# TODO: delegate to convert_method_missing
log :warn, %(missing convert handler for #{name} node in #{@backend} backend)
end
# NOTE: inline nodes generate pseudo-HTML strings; the remainder write directly to PDF object
::Asciidoctor::Inline === node ? result : self
end
def traverse node, opts = {}
# NOTE: converter instance in scratch document gets duplicated; must be rewired to this one
if self == (prev_converter = node.document.converter)
prev_converter = nil
else
node.document.instance_variable_set :@converter, self
end
if node.blocks?
node.content
elsif node.content_model != :compound && (string = node.content)
# TODO: this content could be cached on repeat invocations!
layout_prose string, (opts.merge hyphenate: true)
end
ensure
node.document.instance_variable_set :@converter, prev_converter if prev_converter
end
def log severity, message = nil, &block
logger.send severity, message, &block unless scratch?
end
def convert_document doc
doc.promote_preface_block
init_pdf doc
# set default value for outline and pagenums if not otherwise set
doc.attributes['outline'] = '' unless (doc.attribute_locked? 'outline') || ((doc.instance_variable_get :@attributes_modified).include? 'outline')
doc.attributes['pagenums'] = '' unless (doc.attribute_locked? 'pagenums') || ((doc.instance_variable_get :@attributes_modified).include? 'pagenums')
#assign_missing_section_ids doc
on_page_create(&(method :init_page))
marked_page_number = page_number
# NOTE: a new page will already be started (page_number = 2) if the front cover image is a PDF
layout_cover_page doc, :front
has_front_cover = page_number > marked_page_number
if (use_title_page = doc.doctype == 'book' || (doc.attr? 'title-page'))
layout_title_page doc
has_title_page = page_number == (has_front_cover ? 2 : 1)
end
@page_margin_by_side[:cover] = @page_margin_by_side[:recto] if @media == 'prepress' && page_number == 0
start_new_page unless page.empty?
# NOTE: font must be set before content is written to the main or scratch document
font @theme.base_font_family, size: @root_font_size, style: @theme.base_font_style unless has_title_page
unless use_title_page
body_start_page_number = page_number
theme_font :heading, level: 1 do
layout_heading doc.doctitle, align: (@theme.heading_h1_align&.to_sym || :center), level: 1
end if doc.header? && !doc.notitle
end
num_front_matter_pages = toc_page_nums = toc_num_levels = nil
indent_section do
toc_num_levels = (doc.attr 'toclevels', 2).to_i
if (insert_toc = (doc.attr? 'toc') && !((toc_placement = doc.attr 'toc-placement') == 'macro' || toc_placement == 'preamble') && doc.sections?)
start_new_page if @ppbook && verso_page?
add_dest_for_block doc, 'toc'
allocate_toc doc, toc_num_levels, @y, use_title_page
else
@toc_extent = nil
end
start_new_page if @ppbook && verso_page?
if use_title_page
zero_page_offset = has_front_cover ? 1 : 0
first_page_offset = has_title_page ? zero_page_offset.next : zero_page_offset
body_offset = (body_start_page_number = page_number) - 1
if ::Integer === (running_content_start_at = @theme.running_content_start_at)
running_content_body_offset = body_offset + [running_content_start_at.pred, 1].max
running_content_start_at = 'body'
else
running_content_body_offset = body_offset
case running_content_start_at
when 'title'
running_content_start_at = 'toc' unless has_title_page
when 'toc'
running_content_start_at = 'body' unless insert_toc
when 'after-toc'
running_content_start_at = 'body'
end
end
if ::Integer === (page_numbering_start_at = @theme.page_numbering_start_at)
page_numbering_body_offset = body_offset + [page_numbering_start_at.pred, 1].max
page_numbering_start_at = 'body'
else
page_numbering_body_offset = body_offset
case page_numbering_start_at
when 'cover'
if has_front_cover
page_numbering_body_offset = 0
else
page_numbering_start_at = 'title'
end
when 'title'
page_numbering_start_at = 'toc' unless has_title_page
when 'toc'
page_numbering_start_at = 'body' unless insert_toc
when 'after-toc'
page_numbering_start_at = 'body'
end
end
# table values are number of pages to skip before starting running content and page numbering, respectively
num_front_matter_pages = {
%w(title cover) => [zero_page_offset, page_numbering_body_offset],
%w(title title) => [zero_page_offset, zero_page_offset],
%w(title toc) => [zero_page_offset, first_page_offset],
%w(title body) => [zero_page_offset, page_numbering_body_offset],
%w(toc cover) => [first_page_offset, page_numbering_body_offset],
%w(toc title) => [first_page_offset, zero_page_offset],
%w(toc toc) => [first_page_offset, first_page_offset],
%w(toc body) => [first_page_offset, page_numbering_body_offset],
%w(body cover) => [running_content_body_offset, page_numbering_body_offset],
%w(body title) => [running_content_body_offset, zero_page_offset],
%w(body toc) => [running_content_body_offset, first_page_offset],
}[[running_content_start_at, page_numbering_start_at]] || [running_content_body_offset, page_numbering_body_offset]
else
body_offset = body_start_page_number - 1
if ::Integer === (running_content_start_at = @theme.running_content_start_at)
running_content_body_offset = body_offset + [running_content_start_at.pred, 1].max
else
running_content_body_offset = body_offset
end
if ::Integer === (page_numbering_start_at = @theme.page_numbering_start_at)
page_numbering_body_offset = body_offset + [page_numbering_start_at.pred, 1].max
elsif page_numbering_start_at == 'cover' && has_front_cover
page_numbering_body_offset = 0
else
page_numbering_body_offset = body_offset
end
num_front_matter_pages = [running_content_body_offset, page_numbering_body_offset]
end
@index.start_page_number = num_front_matter_pages[1] + 1
doc.set_attr 'pdf-anchor', (derive_anchor_from_id doc.id, 'top')
doc.set_attr 'pdf-page-start', page_number
convert_section generate_manname_section doc if doc.doctype == 'manpage' && (doc.attr? 'manpurpose')
traverse doc
# NOTE: for a book, these are leftover footnotes; for an article this is everything
outdent_section { layout_footnotes doc }
if @toc_extent
if use_title_page && !insert_toc
num_front_matter_pages[0] = @toc_extent[:page_nums].last if @theme.running_content_start_at == 'after-toc'
num_front_matter_pages[1] = @toc_extent[:page_nums].last if @theme.page_numbering_start_at == 'after-toc'
end
toc_page_nums = layout_toc doc, toc_num_levels, @toc_extent[:page_nums].first, @toc_extent[:start_y], num_front_matter_pages[1]
else
toc_page_nums = []
end
# NOTE: delete orphaned page (a page was created but there was no additional content)
# QUESTION: should we delete page if document is empty? (leaving no pages?)
delete_page if page_count > 1 && page.empty?
end
unless page_count < body_start_page_number
layout_running_content :header, doc, num_front_matter_pages, body_start_page_number unless doc.noheader || @theme.header_height.to_f == 0
layout_running_content :footer, doc, num_front_matter_pages, body_start_page_number unless doc.nofooter || @theme.footer_height.to_f == 0
end
add_outline doc, (doc.attr 'outlinelevels', toc_num_levels), toc_page_nums, num_front_matter_pages[1], has_front_cover
if (initial_zoom = @theme.page_initial_zoom&.to_sym)
case initial_zoom
when :Fit
catalog.data[:OpenAction] = dest_fit state.pages[0]
when :FitV
catalog.data[:OpenAction] = dest_fit_vertically 0, state.pages[0]
when :FitH
catalog.data[:OpenAction] = dest_fit_horizontally page_height, state.pages[0]
end
end
catalog.data[:ViewerPreferences] = { DisplayDocTitle: true }
stamp_foreground_image doc, has_front_cover
layout_cover_page doc, :back
add_dest_for_top doc
nil
end
# NOTE: embedded only makes sense if perhaps we are building
# on an existing Prawn::Document instance; for now, just treat
# it the same as a full document.
alias convert_embedded convert_document
def init_pdf doc
(instance_variables - @initial_instance_variables).each {|ivar| remove_instance_variable ivar } if state
pdf_opts = build_pdf_options doc, (theme = load_theme doc)
# QUESTION: should page options be preserved? (otherwise, not readily available)
#@page_opts = { size: pdf_opts[:page_size], layout: pdf_opts[:page_layout] }
((::Prawn::Document.instance_method :initialize).bind self).call pdf_opts
renderer.min_version (@pdf_version = PDFVersions[doc.attr 'pdf-version'])
@page_margin_by_side = { recto: page_margin, verso: page_margin, cover: page_margin }
if (@media = doc.attr 'media', 'screen') == 'prepress'
@ppbook = doc.doctype == 'book'
page_margin_recto = @page_margin_by_side[:recto]
if (page_margin_outer = theme.page_margin_outer)
page_margin_recto[1] = @page_margin_by_side[:verso][3] = page_margin_outer
end
if (page_margin_inner = theme.page_margin_inner)
page_margin_recto[3] = @page_margin_by_side[:verso][1] = page_margin_inner
end
# NOTE: prepare scratch document to use page margin from recto side (which has same width as verso side)
set_page_margin page_margin_recto unless page_margin_recto == page_margin
else
@ppbook = nil
end
# QUESTION: should ThemeLoader handle registering fonts instead?
register_fonts theme.font_catalog, (doc.attr 'pdf-fontsdir', 'GEM_FONTS_DIR')
default_kerning theme.base_font_kerning != 'none'
@fallback_fonts = Array theme.font_fallbacks
@allow_uri_read = doc.attr? 'allow-uri-read'
@cache_uri = doc.attr? 'cache-uri'
@tmp_files = {}
if (bg_image = resolve_background_image doc, theme, 'page-background-image')&.first
@page_bg_image = { verso: bg_image, recto: bg_image }
else
@page_bg_image = { verso: nil, recto: nil }
end
if (bg_image = resolve_background_image doc, theme, 'page-background-image-verso')
@page_bg_image[:verso] = bg_image[0] && bg_image
end
if (bg_image = resolve_background_image doc, theme, 'page-background-image-recto')
@page_bg_image[:recto] = bg_image[0] && bg_image
end
@page_bg_color = resolve_theme_color :page_background_color, 'FFFFFF'
@root_font_size = theme.base_font_size
@font_scale = 1
@font_color = theme.base_font_color
@text_decoration_width = theme.base_text_decoration_width
@base_align = (align = doc.attr 'text-align') && (TextAlignmentNames.include? align) ? align : theme.base_align
@cjk_line_breaks = doc.attr? 'scripts', 'cjk'
if (hyphen_lang = doc.attr 'hyphens') &&
((defined? ::Text::Hyphen::VERSION) || !(Helpers.require_library 'text/hyphen', 'text-hyphen', :warn).nil?)
hyphen_lang = doc.attr 'lang' if hyphen_lang.empty?
hyphen_lang = 'en_us' if hyphen_lang.nil_or_empty? || hyphen_lang == 'en'
hyphen_lang = (hyphen_lang.tr '-', '_').downcase
@hyphenator = ::Text::Hyphen.new language: hyphen_lang
end
@text_transform = nil
@list_numerals = []
@list_bullets = []
@rendered_footnotes = []
@conum_glyphs = ConumSets[@theme.conum_glyphs || 'circled'] || (@theme.conum_glyphs.split ',').map {|r|
from, to = r.rstrip.split '-', 2
to ? ((get_char from)..(get_char to)).to_a : [(get_char from)]
}.flatten
@section_indent = (val = @theme.section_indent) && (expand_indent_value val)
@toc_max_pagenum_digits = (doc.attr 'toc-max-pagenum-digits', 3).to_i
@disable_running_content = {}
@index ||= IndexCatalog.new
# NOTE: we have to init Pdfmark class here while we have reference to the doc
@pdfmark = (doc.attr? 'pdfmark') ? (Pdfmark.new doc) : nil
# NOTE: defer instantiating optimizer until we know min pdf version
if (@optimize = doc.attr 'optimize')
@optimize = nil unless (defined? ::Asciidoctor::PDF::Optimizer) || !(Helpers.require_library OptimizerRequirePath, 'rghost', :warn).nil?
end
init_scratch_prototype
self
end
def load_theme doc
@theme ||= begin # rubocop:disable Naming/MemoizedInstanceVariableName
if (theme = doc.options[:pdf_theme])
theme = theme.dup
@themesdir = ::File.expand_path theme.__dir__ || (doc.attr 'pdf-themesdir') || (doc.attr 'pdf-stylesdir') || ::Dir.pwd
elsif (theme_name = (doc.attr 'pdf-theme') || (doc.attr 'pdf-style'))
theme = ThemeLoader.load_theme theme_name, (user_themesdir = (doc.attr 'pdf-themesdir') || (doc.attr 'pdf-stylesdir'))
@themesdir = theme.__dir__
else
@themesdir = (theme = ThemeLoader.load_theme).__dir__
end
prepare_theme theme
rescue
if user_themesdir
message = %(could not locate or load the pdf theme `#{theme_name}' in #{user_themesdir})
else
message = %(could not locate or load the built-in pdf theme `#{theme_name}')
end
message += %( because of #{$!.class} #{$!.message})
log :error, %(#{message}; reverting to default theme)
@themesdir = (theme = ThemeLoader.load_theme).__dir__
prepare_theme theme
end
end
def prepare_theme theme
theme.base_border_width || 0
theme.base_font_color ||= '000000'
theme.base_font_size ||= 12
theme.base_font_style = theme.base_font_style&.to_sym || :normal
theme.page_numbering_start_at ||= 'body'
theme.running_content_start_at ||= 'body'
theme.heading_margin_page_top ||= 0
theme.heading_margin_top ||= 0
theme.heading_margin_bottom ||= 0
theme.prose_text_indent ||= 0
theme.prose_margin_bottom ||= 0
theme.block_margin_bottom ||= 0
theme.outline_list_indent ||= 0
theme.outline_list_item_spacing ||= 0
theme.description_list_term_spacing ||= 0
theme.description_list_description_indent ||= 0
theme.image_border_width ||= 0
theme.code_linenum_font_color ||= '999999'
theme.role_unresolved_font_color ||= 'FF0000'
theme.index_columns ||= 2
theme.footnotes_item_spacing ||= 0
theme.key_separator ||= '+'
theme.title_page_authors_delimiter ||= ', '
theme.title_page_revision_delimiter ||= ', '
theme.toc_hanging_indent ||= 0
theme
end
def build_pdf_options doc, theme
case (page_margin = (doc.attr 'pdf-page-margin') || theme.page_margin)
when ::Array
if page_margin.empty?
page_margin = nil
else
page_margin = page_margin.slice 0, 4 if page_margin.length > 4
page_margin = page_margin.map {|v| ::Numeric === v ? v : (str_to_pt v.to_s) }
end
when ::Numeric
page_margin = [page_margin]
when ::String
if page_margin.empty?
page_margin = nil
elsif (page_margin.start_with? '[') && (page_margin.end_with? ']')
if (page_margin = (page_margin.slice 1, page_margin.length - 2).rstrip).empty?
page_margin = nil
else
if (page_margin = page_margin.split ',', -1).length > 4
page_margin = page_margin.slice 0, 4
end
page_margin = page_margin.map {|v| str_to_pt v.rstrip }
end
else
page_margin = [(str_to_pt page_margin)]
end
else
page_margin = nil
end
if (doc.attr? 'pdf-page-size') && PageSizeRx =~ (doc.attr 'pdf-page-size')
# e.g, [8.5in, 11in]
if $1
page_size = [$1, $2]
# e.g, 8.5in x 11in
elsif $3
page_size = [$3, $4]
# e.g, A4
else
page_size = $&
end
else
page_size = theme.page_size
end
case page_size
when ::String, ::Symbol
# TODO: extract helper method to check for named page size
page_size = page_size.to_s.upcase
page_size = nil unless ::PDF::Core::PageGeometry::SIZES.key? page_size
when ::Array
if page_size.empty?
page_size = nil
else
page_size[1] ||= page_size[0]
page_size = (page_size.slice 0, 2).map do |dim|
if ::Numeric === dim
# dimension cannot be less than 0
dim > 0 ? dim : break
elsif ::String === dim && MeasurementPartsRx =~ dim
# NOTE: truncate to max precision retained by PDF::Core
(dim = (to_pt $1.to_f, $2).truncate 4) > 0 ? dim : break
else
break
end
end
end
else
page_size = nil
end
if (page_layout = (doc.attr 'pdf-page-layout') || theme.page_layout).nil_or_empty? ||
!(PageLayouts.include? (page_layout = page_layout.to_sym))
page_layout = nil
end
{
margin: (page_margin || 36),
page_size: (page_size || 'A4'),
page_layout: (page_layout || :portrait),
info: (build_pdf_info doc),
compress: (doc.attr? 'compress'),
skip_page_creation: true,
text_formatter: (FormattedText::Formatter.new theme: theme),
}
end
# FIXME: Pdfmark should use the PDF info result
def build_pdf_info doc
info = {}
if (doctitle = resolve_doctitle doc)
info[:Title] = (sanitize doctitle).as_pdf
end
if (doc.attribute_locked? 'author') && !(doc.attribute_locked? 'authors')
info[:Author] = (sanitize doc.attr 'author').as_pdf
elsif doc.attr? 'authors'
info[:Author] = (sanitize doc.attr 'authors').as_pdf
end
info[:Subject] = (sanitize doc.attr 'subject').as_pdf if doc.attr? 'subject'
info[:Keywords] = (sanitize doc.attr 'keywords').as_pdf if doc.attr? 'keywords'
info[:Producer] = (sanitize doc.attr 'publisher').as_pdf if doc.attr? 'publisher'
if doc.attr? 'reproducible'
info[:Creator] = 'Asciidoctor PDF, based on Prawn'.as_pdf
info[:Producer] ||= (info[:Author] || info[:Creator])
else
info[:Creator] = %(Asciidoctor PDF #{::Asciidoctor::PDF::VERSION}, based on Prawn #{::Prawn::VERSION}).as_pdf
info[:Producer] ||= (info[:Author] || info[:Creator])
# NOTE: since we don't track the creation date of the input file, we map the ModDate header to the last modified
# date of the input document and the CreationDate header to the date the PDF was produced by the converter.
info[:ModDate] = (::Time.parse doc.attr 'docdatetime') rescue (now ||= ::Time.now)
info[:CreationDate] = (::Time.parse doc.attr 'localdatetime') rescue (now || ::Time.now)
end
info
end
# NOTE: init_page is called within a float context
# NOTE: init_page is not called for imported pages, front and back cover pages, and other image pages
def init_page *_args
# NOTE: we assume in prepress that physical page number reflects page side
if @media == 'prepress' &&
(next_page_margin = @page_margin_by_side[page_number == 1 ? :cover : page_side]) != page_margin
set_page_margin next_page_margin
end
unless @page_bg_color == 'FFFFFF'
tare = true
fill_absolute_bounds @page_bg_color
end
if (bg_image = @page_bg_image[page_side])
tare = true
# NOTE: float is necessary since prawn-svg may mess with cursor position
float { canvas { image bg_image[0], ({ position: :center, vposition: :center }.merge bg_image[1]) } }
end
page.tare_content_stream if tare
end
def convert_section sect, _opts = {}
if sect.sectname == 'abstract'
# HACK: cheat a bit to hide this section from TOC; TOC should filter these sections
sect.context = :open
return convert_abstract sect
elsif (index_section = sect.sectname == 'index')
if @index.empty?
sect.parent.blocks.delete sect
return
end
end
type = nil
title = sect.numbered_title formal: true
sep = (sect.attr 'separator') || (sect.document.attr 'title-separator') || ''
if !sep.empty? && title.include?(sep = %(#{sep} ))
title, _, subtitle = title.rpartition sep
title = %(#{title}\n<em class="subtitle">#{subtitle}</em>)
end
theme_font :heading, level: (hlevel = sect.level + 1) do
align = (@theme[%(heading_h#{hlevel}_align)] || @theme.heading_align || @base_align).to_sym
if sect.part_or_chapter?
if sect.chapter?
type = :chapter
if @theme.heading_chapter_break_before == 'auto'
start_new_chapter sect if @theme.heading_part_break_after == 'always' && sect == sect.parent.sections[0]
else
start_new_chapter sect
end
else
type = :part
start_new_part sect unless @theme.heading_part_break_before == 'auto'
end
end
unless at_page_top?
# FIXME: this height doesn't account for impact of text transform or inline formatting
heading_height =
(height_of_typeset_text title, line_height: (@theme[%(heading_h#{hlevel}_line_height)] || @theme.heading_line_height)) +
(@theme[%(heading_h#{hlevel}_margin_top)] || @theme.heading_margin_top) +
(@theme[%(heading_h#{hlevel}_margin_bottom)] || @theme.heading_margin_bottom)
heading_height += @theme.heading_min_height_after if sect.blocks? && @theme.heading_min_height_after
start_new_page unless cursor > heading_height
end
# QUESTION: should we store pdf-page-start, pdf-anchor & pdf-destination in internal map?
sect.set_attr 'pdf-page-start', (start_pgnum = page_number)
# QUESTION: should we just assign the section this generated id?
# NOTE: section must have pdf-anchor in order to be listed in the TOC
sect.set_attr 'pdf-anchor', (sect_anchor = derive_anchor_from_id sect.id, %(#{start_pgnum}-#{y.ceil}))
add_dest_for_block sect, sect_anchor
case type
when :part
layout_part_title sect, title, align: align, level: hlevel
when :chapter
layout_chapter_title sect, title, align: align, level: hlevel
else
layout_heading title, align: align, level: hlevel, outdent: true
end
end
if index_section
outdent_section { convert_index_section sect }
else
traverse sect
end
outdent_section { layout_footnotes sect } if type == :chapter
sect.set_attr 'pdf-page-end', page_number
end
def indent_section
if (values = @section_indent)
indent(values[0], values[1]) { yield }
else
yield
end
end
def outdent_section enabled = true
if enabled && (values = @section_indent)
indent(-values[0], -values[1]) { yield }
else
yield
end
end
# QUESTION: if a footnote ref appears in a separate chapter, should the footnote def be duplicated?
def layout_footnotes node
return if (fns = (doc = node.document).footnotes - @rendered_footnotes).empty?
theme_margin :footnotes, :top
theme_font :footnotes do
(title = doc.attr 'footnotes-title') && (layout_caption title, category: :footnotes)
item_spacing = @theme.footnotes_item_spacing
index_offset = @rendered_footnotes.length
sect_xreftext = node.context == :section && (node.xreftext node.document.attr 'xrefstyle')
fns.each do |fn|
label = (index = fn.index) - index_offset
if sect_xreftext
fn.singleton_class.send :attr_accessor, :label unless fn.respond_to? :label=
fn.label = %(#{label} - #{sect_xreftext})
end
layout_prose %(<a id="_footnotedef_#{index}">#{DummyText}</a>[<a anchor="_footnoteref_#{index}">#{label}</a>] #{fn.text}), margin_bottom: item_spacing, hyphenate: true
end
@rendered_footnotes += fns
end
nil
end
def convert_floating_title node
add_dest_for_block node if node.id
hlevel = node.level.next
unless (align = resolve_alignment_from_role node.roles)
align = (@theme[%(heading_h#{hlevel}_align)] || @theme.heading_align || @base_align).to_sym
end
# QUESTION: should we decouple styles from section titles?
theme_font :heading, level: hlevel do
layout_heading node.title, align: align, level: hlevel, outdent: (node.parent.context == :section)
end
end
def convert_abstract node
add_dest_for_block node if node.id
outdent_section do
pad_box @theme.abstract_padding do
theme_font :abstract_title do
layout_prose node.title, align: (@theme.abstract_title_align || @base_align).to_sym, margin_top: @theme.heading_margin_top, margin_bottom: @theme.heading_margin_bottom, line_height: @theme.heading_line_height
end if node.title?
theme_font :abstract do
prose_opts = { line_height: @theme.abstract_line_height, align: (@theme.abstract_align || @base_align).to_sym, hyphenate: true }
if (text_indent = @theme.prose_text_indent) > 0
prose_opts[:indent_paragraphs] = text_indent
end
# FIXME: allow theme to control more first line options
if (line1_font_style = @theme.abstract_first_line_font_style&.to_sym) && line1_font_style != font_style
first_line_options = { styles: line1_font_style == :normal ? [] : [font_style, line1_font_style] }
end
if (line1_font_color = @theme.abstract_first_line_font_color)
(first_line_options ||= {})[:color] = line1_font_color
end
prose_opts[:first_line_options] = first_line_options if first_line_options
# FIXME: make this cleaner!!
if node.blocks?
node.blocks.each do |child|
if child.context == :paragraph
child.document.playback_attributes child.attributes
layout_prose child.content, ((align = resolve_alignment_from_role child.roles) ? (prose_opts.merge align: align) : prose_opts.dup)
prose_opts.delete :first_line_options
else
# FIXME: this could do strange things if the wrong kind of content shows up
child.convert
end
end
elsif node.content_model != :compound && (string = node.content)
if (align = resolve_alignment_from_role node.roles)
prose_opts[:align] = align
end
layout_prose string, prose_opts
end
end
end
# QUESTION: should we be adding margin below the abstract??
#theme_margin :block, :bottom
end
end
def convert_preamble node
# FIXME: core should not be promoting paragraph to preamble if there are no sections
if node.blocks? && (first_block = node.blocks[0]).context == :paragraph && node.document.sections?
first_block.add_role 'lead' unless first_block.role?
end
traverse node
convert_toc node, placement: 'preamble'
end
def convert_paragraph node
add_dest_for_block node if node.id
prose_opts = { margin_bottom: 0, hyphenate: true }
lead = (roles = node.roles).include? 'lead'
if (align = resolve_alignment_from_role roles)
prose_opts[:align] = align
end
if (text_indent = @theme.prose_text_indent) > 0
prose_opts[:indent_paragraphs] = text_indent
end
# TODO: check if we're within one line of the bottom of the page
# and advance to the next page if so (similar to logic for section titles)
layout_caption node, labeled: false if node.title?
if lead
theme_font :lead do
layout_prose node.content, prose_opts
end
else
layout_prose node.content, prose_opts
end
if (margin_inner_val = @theme.prose_margin_inner) &&
(next_block = (siblings = node.parent.blocks)[(siblings.index node) + 1]) && next_block.context == :paragraph
margin_bottom margin_inner_val
else
margin_bottom @theme.prose_margin_bottom
end
end
def convert_admonition node
add_dest_for_block node if node.id
theme_margin :block, :top
type = node.attr 'name'
label_align = @theme.admonition_label_align&.to_sym || :center
# TODO: allow vertical_align to be a number
if (label_valign = @theme.admonition_label_vertical_align&.to_sym || :middle) == :middle
label_valign = :center
end
if (label_min_width = @theme.admonition_label_min_width)
label_min_width = label_min_width.to_f
end
if (doc = node.document).attr? 'icons'
if (doc.attr 'icons') == 'font' && !(node.attr? 'icon')
icons = 'font'
label_text = type.to_sym
icon_data = admonition_icon_data label_text
icon_size = icon_data[:size] || 24
label_width = label_min_width || (icon_size * 1.5)
elsif (icon_path = resolve_icon_image_path node, type) && (::File.readable? icon_path)
icons = true
# TODO: introduce @theme.admonition_image_width? or use size key from admonition_icon_<name>?
label_width = label_min_width || 36.0
else
log :warn, %(admonition icon not found or not readable: #{icon_path || (resolve_icon_image_path node, type, false)})
end
end
unless icons
label_text = sanitize node.caption
theme_font :admonition_label do
theme_font %(admonition_label_#{type}) do
label_text = transform_text label_text, @text_transform if @text_transform
label_width = rendered_width_of_string label_text
label_width = label_min_width if label_min_width && label_min_width > label_width
end
end
end
unless ::Array === (cpad = @theme.admonition_padding || 0)
cpad = ::Array.new 4, cpad
end
unless ::Array === (lpad = @theme.admonition_label_padding || cpad)
lpad = ::Array.new 4, lpad
end
# FIXME: this shift stuff is a real hack until we have proper margin collapsing
shift_base = @theme.prose_margin_bottom
shift_top = shift_base / 3.0
shift_bottom = (shift_base * 2) / 3.0
keep_together do |box_height = nil|
push_scratch doc if scratch?
theme_fill_and_stroke_block :admonition, box_height if box_height
pad_box [0, cpad[1], 0, lpad[3]] do
if box_height
label_height = [box_height, cursor].min
if (rule_color = @theme.admonition_column_rule_color) &&
(rule_width = @theme.admonition_column_rule_width || @theme.base_border_width) && rule_width > 0
float do
rule_height = box_height
while rule_height > 0
rule_segment_height = [rule_height, cursor].min
bounding_box [0, cursor], width: label_width + lpad[1], height: rule_segment_height do
stroke_vertical_rule rule_color,
at: bounds.right,
line_style: (@theme.admonition_column_rule_style&.to_sym || :solid),
line_width: rule_width
end
advance_page if (rule_height -= rule_segment_height) > 0
end
end
end
float do
adjusted_font_size = nil
bounding_box [0, cursor], width: label_width, height: label_height do
if icons == 'font'
# FIXME: we assume icon is square
icon_size = fit_icon_to_bounds icon_size
# NOTE: Prawn's vertical center is not reliable, so calculate it manually
if label_valign == :center
label_valign = :top
if (vcenter_pos = (label_height - icon_size) * 0.5) > 0
move_down vcenter_pos
end
end
icon icon_data[:name],
valign: label_valign,
align: label_align,
color: (icon_data[:stroke_color] || font_color),
size: icon_size
elsif icons
if (::Asciidoctor::Image.format icon_path) == 'svg'
begin
svg_obj = ::Prawn::SVG::Interface.new (::File.read icon_path, mode: 'r:UTF-8'), self,
position: label_align,
vposition: label_valign,
width: label_width,
height: label_height,
fallback_font_name: fallback_svg_font_name,
enable_web_requests: allow_uri_read ? (method :load_open_uri).to_proc : false,
enable_file_requests_with_root: (::File.dirname icon_path),
cache_images: cache_uri
svg_obj.resize height: label_height if svg_obj.document.sizing.output_height > label_height
svg_obj.draw
svg_obj.document.warnings.each do |icon_warning|
log :warn, %(problem encountered in image: #{icon_path}; #{icon_warning})
end unless scratch?
rescue
log :warn, %(could not embed admonition icon: #{icon_path}; #{$!.message})
icons = nil
end
else
begin
image_obj, image_info = ::File.open(icon_path, 'rb') {|fd| build_image_object fd }
icon_aspect_ratio = image_info.width.fdiv image_info.height
# NOTE: don't scale image up if smaller than label_width
icon_width = [(to_pt image_info.width, :px), label_width].min
if (icon_height = icon_width * (1 / icon_aspect_ratio)) > label_height
icon_width *= label_height / icon_height
end
embed_image image_obj, image_info, width: icon_width, position: label_align, vposition: label_valign
rescue
# QUESTION: should we show the label in this case?
log :warn, %(could not embed admonition icon: #{icon_path}; #{$!.message})
icons = nil
end
end
unless icons
label_text = sanitize node.caption
theme_font :admonition_label do
theme_font %(admonition_label_#{type}) do
label_text = transform_text label_text, @text_transform if @text_transform
# NOTE: make sure the textual label fits in space already reserved
if (actual_label_width = rendered_width_of_string label_text) > label_width
adjusted_font_size = font_size * label_width / actual_label_width
end
end
end
redo
end
else
# NOTE: the label must fit in the alotted space or it shows up on another page!
# QUESTION: anyway to prevent text overflow in the case it doesn't fit?
theme_font :admonition_label do
theme_font %(admonition_label_#{type}) do
font_size adjusted_font_size if adjusted_font_size
# NOTE: Prawn's vertical center is not reliable, so calculate it manually
if label_valign == :center
label_valign = :top
if (vcenter_pos = (label_height - (height_of_typeset_text label_text, line_height: 1)) * 0.5) > 0
move_down vcenter_pos
end
end
@text_transform = nil # already applied to label
layout_prose label_text,
align: label_align,
valign: label_valign,
line_height: 1,
margin: 0,
inline_format: false, # already replaced character references
overflow: :shrink_to_fit,
disable_wrap_by_char: true
end
end
end
end
end
end
pad_box [cpad[0], 0, cpad[2], label_width + lpad[1] + cpad[3]] do
move_down shift_top
layout_caption node, category: :admonition, labeled: false if node.title?
theme_font :admonition do
traverse node
end
# FIXME: HACK compensate for margin bottom of admonition content
move_up shift_bottom unless at_page_top?
end
end
pop_scratch doc if scratch?
end
theme_margin :block, :bottom
end
def convert_example node
return convert_open node if node.option? 'collapsible'
add_dest_for_block node if node.id
theme_margin :block, :top