"Fossies" - the Fresh Open Source Software Archive

Member "asciidoctor-2.0.10/lib/asciidoctor/document.rb" (1 Jun 2019, 53740 Bytes) of package /linux/www/asciidoctor-2.0.10.tar.gz:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Ruby source code syntax highlighting (style: standard) with prefixed line numbers and code folding option. Alternatively you can here view or download the uninterpreted source code file. See also the latest Fossies "Diffs" side-by-side code changes report for "document.rb": 2.0.9_vs_2.0.10.

    1 # frozen_string_literal: true
    2 module Asciidoctor
    3 # Public: The Document class represents a parsed AsciiDoc document.
    4 #
    5 # Document is the root node of a parsed AsciiDoc document. It provides an
    6 # abstract syntax tree (AST) that represents the structure of the AsciiDoc
    7 # document from which the Document object was parsed.
    8 #
    9 # Although the constructor can be used to create an empty document object, more
   10 # commonly, you'll load the document object from AsciiDoc source using the
   11 # primary API methods, {Asciidoctor.load} or {Asciidoctor.load_file}. When
   12 # using one of these APIs, you almost always want to set the safe mode to
   13 # :safe (or :unsafe) to enable all of Asciidoctor's features.
   14 #
   15 #   Asciidoctor.load '= Hello, AsciiDoc!', safe: :safe
   16 #   # => Asciidoctor::Document { doctype: "article", doctitle: "Hello, AsciiDoc!", blocks: 0 }
   17 #
   18 # Instances of this class can be used to extract information from the document
   19 # or alter its structure. As such, the Document object is most often used in
   20 # extensions and by integrations.
   21 #
   22 # The most basic usage of the Document object is to retrieve the document's
   23 # title.
   24 #
   25 #   source = '= Document Title'
   26 #   document = Asciidoctor.load source, safe: :safe
   27 #   document.doctitle
   28 #   # => 'Document Title'
   29 #
   30 # If the document has no title, the {Document#doctitle} method returns the
   31 # title of the first section. If that check falls through, you can have the
   32 # method return a fallback value (the value of the untitled-label attribute).
   33 #
   34 #   Asciidoctor.load('no doctitle', safe: :safe).doctitle use_fallback: true
   35 #   # => "Untitled"
   36 #
   37 # You can also use the Document object to access document attributes defined in
   38 # the header, such as the author and doctype.
   39 #
   40 #   source = '= Document Title
   41 #   Author Name
   42 #   :doctype: book'
   43 #   document = Asciidoctor.load source, safe: :safe
   44 #   document.author
   45 #   # => 'Author Name'
   46 #   document.doctype
   47 #   # => 'book'
   48 #
   49 # You can retrieve arbitrary document attributes defined in the header using
   50 # {Document#attr} or check for the existence of one using {Document#attr?}:
   51 #
   52 #   source = '= Asciidoctor
   53 #   :uri-project: https://asciidoctor.org'
   54 #   document = Asciidoctor.load source, safe: :safe
   55 #   document.attr 'uri-project'
   56 #   # => 'https://asciidoctor.org'
   57 #   document.attr? 'icons'
   58 #   # => false
   59 #
   60 # Starting at the Document object, you can begin walking the document tree using
   61 # the {Document#blocks} method:
   62 #
   63 #   source = 'paragraph contents
   64 #
   65 #   [sidebar]
   66 #   sidebar contents'
   67 #   doc = Asciidoctor.load source, safe: :safe
   68 #   doc.blocks.map {|block| block.context }
   69 #   # => [:paragraph, :sidebar]
   70 #
   71 # You can discover block nodes at any depth in the tree using the
   72 # {AbstractBlock#find_by} method.
   73 #
   74 #   source = '****
   75 #   paragraph in sidebar
   76 #   ****'
   77 #   doc = Asciidoctor.load source, safe: :safe
   78 #   doc.find_by(context: :paragraph).map {|block| block.context }
   79 #   # => [:paragraph]
   80 #
   81 # Loading a document object is the first step in the conversion process. You
   82 # can take the process to completion by calling the {Document#convert} method.
   83 class Document < AbstractBlock
   84 
   85   ImageReference = ::Struct.new :target, :imagesdir do
   86     alias to_s target
   87   end
   88 
   89   Footnote = ::Struct.new :index, :id, :text
   90 
   91   class AttributeEntry
   92     attr_reader :name, :value, :negate
   93 
   94     def initialize name, value, negate = nil
   95       @name = name
   96       @value = value
   97       @negate = negate.nil? ? value.nil? : negate
   98     end
   99 
  100     def save_to block_attributes
  101       (block_attributes[:attribute_entries] ||= []) << self
  102       self
  103     end
  104   end
  105 
  106   # Public Parsed and stores a partitioned title (i.e., title & subtitle).
  107   class Title
  108     attr_reader :main
  109     alias title main
  110     attr_reader :subtitle
  111     attr_reader :combined
  112 
  113     def initialize val, opts = {}
  114       # TODO separate sanitization by type (:cdata for HTML/XML, :plain_text for non-SGML, false for none)
  115       if (@sanitized = opts[:sanitize]) && val.include?('<')
  116         val = val.gsub(XmlSanitizeRx, '').squeeze(' ').strip
  117       end
  118       if (sep = opts[:separator] || ':').empty? || !val.include?(sep = %(#{sep} ))
  119         @main = val
  120         @subtitle = nil
  121       else
  122         @main, _, @subtitle = val.rpartition sep
  123       end
  124       @combined = val
  125     end
  126 
  127     def sanitized?
  128       @sanitized
  129     end
  130 
  131     def subtitle?
  132       @subtitle ? true : false
  133     end
  134 
  135     def to_s
  136       @combined
  137     end
  138   end
  139 
  140   # Public: The Author class represents information about an author extracted from document attributes
  141   Author = ::Struct.new :name, :firstname, :middlename, :lastname, :initials, :email
  142 
  143   # Public A read-only integer value indicating the level of security that
  144   # should be enforced while processing this document. The value must be
  145   # set in the Document constructor using the :safe option.
  146   #
  147   # A value of 0 (UNSAFE) disables any of the security features enforced
  148   # by Asciidoctor (Ruby is still subject to its own restrictions).
  149   #
  150   # A value of 1 (SAFE) closely parallels safe mode in AsciiDoc. In particular,
  151   # it prevents access to files which reside outside of the parent directory
  152   # of the source file and disables any macro other than the include directive.
  153   #
  154   # A value of 10 (SERVER) disallows the document from setting attributes that
  155   # would affect the conversion of the document, in addition to all the security
  156   # features of SafeMode::SAFE. For instance, this level forbids changing the
  157   # backend or source-highlighter using an attribute defined in the source
  158   # document header. This is the most fundamental level of security for server
  159   # deployments (hence the name).
  160   #
  161   # A value of 20 (SECURE) disallows the document from attempting to read files
  162   # from the file system and including the contents of them into the document,
  163   # in addition to all the security features of SafeMode::SECURE. In
  164   # particular, it disallows use of the include::[] directive and the embedding of
  165   # binary content (data uri), stylesheets and JavaScripts referenced by the
  166   # document. (Asciidoctor and trusted extensions may still be allowed to embed
  167   # trusted content into the document).
  168   #
  169   # Since Asciidoctor is aiming for wide adoption, 20 (SECURE) is the default
  170   # value and is recommended for server deployments.
  171   #
  172   # A value of 100 (PARANOID) is planned to disallow the use of passthrough
  173   # macros and prevents the document from setting any known attributes in
  174   # addition to all the security features of SafeMode::SECURE. Please note that
  175   # this level is not currently implemented (and therefore not enforced)!
  176   attr_reader :safe
  177 
  178   # Public: Get the Boolean AsciiDoc compatibility mode
  179   #
  180   # enabling this attribute activates the following syntax changes:
  181   #
  182   #   * single quotes as constrained emphasis formatting marks
  183   #   * single backticks parsed as inline literal, formatted as monospace
  184   #   * single plus parsed as constrained, monospaced inline formatting
  185   #   * double plus parsed as constrained, monospaced inline formatting
  186   #
  187   attr_reader :compat_mode
  188 
  189   # Public: Get the cached value of the backend attribute for this document
  190   attr_reader :backend
  191 
  192   # Public: Get the cached value of the doctype attribute for this document
  193   attr_reader :doctype
  194 
  195   # Public: Get or set the Boolean flag that indicates whether source map information should be tracked by the parser
  196   attr_accessor :sourcemap
  197 
  198   # Public: Get the document catalog Hash
  199   attr_reader :catalog
  200 
  201   # Public: Alias catalog property as references for backwards compatiblity
  202   alias references catalog
  203 
  204   # Public: Get the Hash of document counters
  205   attr_reader :counters
  206 
  207   # Public: Get the level-0 Section (i.e., doctitle). (Only stores the title, not the header attributes).
  208   attr_reader :header
  209 
  210   # Public: Get the String base directory for converting this document.
  211   #
  212   # Defaults to directory of the source file.
  213   # If the source is a string, defaults to the current directory.
  214   attr_reader :base_dir
  215 
  216   # Public: Get the Hash of resolved options used to initialize this Document
  217   attr_reader :options
  218 
  219   # Public: Get the outfilesuffix defined at the end of the header.
  220   attr_reader :outfilesuffix
  221 
  222   # Public: Get a reference to the parent Document of this nested document.
  223   attr_reader :parent_document
  224 
  225   # Public: Get the Reader associated with this document
  226   attr_reader :reader
  227 
  228   # Public: Get/Set the PathResolver instance used to resolve paths in this Document.
  229   attr_reader :path_resolver
  230 
  231   # Public: Get the Converter associated with this document
  232   attr_reader :converter
  233 
  234   # Public: Get the SyntaxHighlighter associated with this document
  235   attr_reader :syntax_highlighter
  236 
  237   # Public: Get the activated Extensions::Registry associated with this document.
  238   attr_reader :extensions
  239 
  240   # Public: Initialize a {Document} object.
  241   #
  242   # data    - The AsciiDoc source data as a String or String Array. (default: nil)
  243   # options - A Hash of options to control processing (e.g., safe mode value (:safe), backend (:backend),
  244   #           standalone enclosure (:standalone), custom attributes (:attributes)). (default: {})
  245   #
  246   # Duplication of the options Hash is handled in the enclosing API.
  247   #
  248   # Examples
  249   #
  250   #   data = File.read filename
  251   #   doc = Asciidoctor::Document.new data
  252   #   puts doc.convert
  253   def initialize data = nil, options = {}
  254     super self, :document
  255 
  256     if (parent_doc = options.delete :parent)
  257       @parent_document = parent_doc
  258       options[:base_dir] ||= parent_doc.base_dir
  259       options[:catalog_assets] = true if parent_doc.options[:catalog_assets]
  260       @catalog = parent_doc.catalog.merge footnotes: []
  261       # QUESTION should we support setting attribute in parent document from nested document?
  262       # NOTE we must dup or else all the assignments to the overrides clobbers the real attributes
  263       @attribute_overrides = attr_overrides = parent_doc.attributes.merge
  264       parent_doctype = attr_overrides.delete 'doctype'
  265       attr_overrides.delete 'compat-mode'
  266       attr_overrides.delete 'toc'
  267       attr_overrides.delete 'toc-placement'
  268       attr_overrides.delete 'toc-position'
  269       @safe = parent_doc.safe
  270       @attributes['compat-mode'] = '' if (@compat_mode = parent_doc.compat_mode)
  271       @outfilesuffix = parent_doc.outfilesuffix
  272       @sourcemap = parent_doc.sourcemap
  273       @timings = nil
  274       @path_resolver = parent_doc.path_resolver
  275       @converter = parent_doc.converter
  276       initialize_extensions = nil
  277       @extensions = parent_doc.extensions
  278       @syntax_highlighter = parent_doc.syntax_highlighter
  279     else
  280       @parent_document = nil
  281       @catalog = {
  282         ids: {}, # deprecated; kept for backwards compatibility with converters
  283         refs: {},
  284         footnotes: [],
  285         links: [],
  286         images: [],
  287         #indexterms: [],
  288         callouts: Callouts.new,
  289         includes: {},
  290       }
  291       # copy attributes map and normalize keys
  292       # attribute overrides are attributes that can only be set from the commandline
  293       # a direct assignment effectively makes the attribute a constant
  294       # a nil value or name with leading or trailing ! will result in the attribute being unassigned
  295       @attribute_overrides = attr_overrides = {}
  296       (options[:attributes] || {}).each do |key, val|
  297         if key.end_with? '@'
  298           if key.start_with? '!'
  299             key, val = (key.slice 1, key.length - 2), false
  300           elsif key.end_with? '!@'
  301             key, val = (key.slice 0, key.length - 2), false
  302           else
  303             key, val = key.chop, %(#{val}@)
  304           end
  305         elsif key.start_with? '!'
  306           key, val = (key.slice 1, key.length), val == '@' ? false : nil
  307         elsif key.end_with? '!'
  308           key, val = key.chop, val == '@' ? false : nil
  309         end
  310         attr_overrides[key.downcase] = val
  311       end
  312       if (to_file = options[:to_file])
  313         attr_overrides['outfilesuffix'] = Helpers.extname to_file
  314       end
  315       # safely resolve the safe mode from const, int or string
  316       if !(safe_mode = options[:safe])
  317         @safe = SafeMode::SECURE
  318       elsif ::Integer === safe_mode
  319         # be permissive in case API user wants to define new levels
  320         @safe = safe_mode
  321       else
  322         @safe = (SafeMode.value_for_name safe_mode) rescue SafeMode::SECURE
  323       end
  324       input_mtime = options.delete :input_mtime
  325       @compat_mode = attr_overrides.key? 'compat-mode'
  326       @sourcemap = options[:sourcemap]
  327       @timings = options.delete :timings
  328       @path_resolver = PathResolver.new
  329       initialize_extensions = (defined? ::Asciidoctor::Extensions) ? true : nil
  330       @extensions = nil # initialize furthur down if initialize_extensions is true
  331       options[:standalone] = options[:header_footer] if (options.key? :header_footer) && !(options.key? :standalone)
  332     end
  333 
  334     @parsed = @reftexts = @header = @header_attributes = nil
  335     @counters = {}
  336     @attributes_modified = ::Set.new
  337     @docinfo_processor_extensions = {}
  338     standalone = options[:standalone]
  339     (@options = options).freeze
  340 
  341     attrs = @attributes
  342     #attrs['encoding'] = 'UTF-8'
  343     attrs['sectids'] = ''
  344     attrs['toc-placement'] = 'auto'
  345     if standalone
  346       attrs['copycss'] = ''
  347       # sync embedded attribute with :standalone option value
  348       attr_overrides['embedded'] = nil
  349     else
  350       attrs['notitle'] = ''
  351       # sync embedded attribute with :standalone option value
  352       attr_overrides['embedded'] = ''
  353     end
  354     attrs['stylesheet'] = ''
  355     attrs['webfonts'] = ''
  356     attrs['prewrap'] = ''
  357     attrs['attribute-undefined'] = Compliance.attribute_undefined
  358     attrs['attribute-missing'] = Compliance.attribute_missing
  359     attrs['iconfont-remote'] = ''
  360 
  361     # language strings
  362     # TODO load these based on language settings
  363     attrs['caution-caption'] = 'Caution'
  364     attrs['important-caption'] = 'Important'
  365     attrs['note-caption'] = 'Note'
  366     attrs['tip-caption'] = 'Tip'
  367     attrs['warning-caption'] = 'Warning'
  368     attrs['example-caption'] = 'Example'
  369     attrs['figure-caption'] = 'Figure'
  370     #attrs['listing-caption'] = 'Listing'
  371     attrs['table-caption'] = 'Table'
  372     attrs['toc-title'] = 'Table of Contents'
  373     #attrs['preface-title'] = 'Preface'
  374     attrs['section-refsig'] = 'Section'
  375     attrs['part-refsig'] = 'Part'
  376     attrs['chapter-refsig'] = 'Chapter'
  377     attrs['appendix-caption'] = attrs['appendix-refsig'] = 'Appendix'
  378     attrs['untitled-label'] = 'Untitled'
  379     attrs['version-label'] = 'Version'
  380     attrs['last-update-label'] = 'Last updated'
  381 
  382     attr_overrides['asciidoctor'] = ''
  383     attr_overrides['asciidoctor-version'] = ::Asciidoctor::VERSION
  384 
  385     attr_overrides['safe-mode-name'] = (safe_mode_name = SafeMode.name_for_value @safe)
  386     attr_overrides["safe-mode-#{safe_mode_name}"] = ''
  387     attr_overrides['safe-mode-level'] = @safe
  388 
  389     # the only way to set the max-include-depth attribute is via the API; default to 64 like AsciiDoc Python
  390     attr_overrides['max-include-depth'] ||= 64
  391 
  392     # the only way to set the allow-uri-read attribute is via the API; disabled by default
  393     attr_overrides['allow-uri-read'] ||= nil
  394 
  395     attr_overrides['user-home'] = USER_HOME
  396 
  397     # remap legacy attribute names
  398     attr_overrides['sectnums'] = attr_overrides.delete 'numbered' if attr_overrides.key? 'numbered'
  399     attr_overrides['hardbreaks-option'] = attr_overrides.delete 'hardbreaks' if attr_overrides.key? 'hardbreaks'
  400 
  401     # If the base_dir option is specified, it overrides docdir and is used as the root for relative
  402     # paths. Otherwise, the base_dir is the directory of the source file (docdir), if set, otherwise
  403     # the current directory.
  404     if (base_dir_val = options[:base_dir])
  405       @base_dir = (attr_overrides['docdir'] = ::File.expand_path base_dir_val)
  406     elsif attr_overrides['docdir']
  407       @base_dir = attr_overrides['docdir']
  408     else
  409       #logger.warn 'setting base_dir is recommended when working with string documents' unless nested?
  410       @base_dir = attr_overrides['docdir'] = ::Dir.pwd
  411     end
  412 
  413     # allow common attributes backend and doctype to be set using options hash, coerce values to string
  414     if (backend_val = options[:backend])
  415       attr_overrides['backend'] = %(#{backend_val})
  416     end
  417 
  418     if (doctype_val = options[:doctype])
  419       attr_overrides['doctype'] = %(#{doctype_val})
  420     end
  421 
  422     if @safe >= SafeMode::SERVER
  423       # restrict document from setting copycss, source-highlighter and backend
  424       attr_overrides['copycss'] ||= nil
  425       attr_overrides['source-highlighter'] ||= nil
  426       attr_overrides['backend'] ||= DEFAULT_BACKEND
  427       # restrict document from seeing the docdir and trim docfile to relative path
  428       if !parent_doc && attr_overrides.key?('docfile')
  429         attr_overrides['docfile'] = attr_overrides['docfile'][(attr_overrides['docdir'].length + 1)..-1]
  430       end
  431       attr_overrides['docdir'] = ''
  432       attr_overrides['user-home'] = '.'
  433       if @safe >= SafeMode::SECURE
  434         attr_overrides['max-attribute-value-size'] = 4096 unless attr_overrides.key? 'max-attribute-value-size'
  435         # assign linkcss (preventing css embedding) unless explicitly disabled from the commandline or API
  436         #attr_overrides['linkcss'] = (attr_overrides.fetch 'linkcss', '') || nil
  437         attr_overrides['linkcss'] = '' unless attr_overrides.key? 'linkcss'
  438         # restrict document from enabling icons
  439         attr_overrides['icons'] ||= nil
  440       end
  441     end
  442 
  443     # the only way to set the max-attribute-value-size attribute is via the API; disabled by default
  444     @max_attribute_value_size = (size = (attr_overrides['max-attribute-value-size'] ||= nil)) ? size.to_i.abs : nil
  445 
  446     attr_overrides.delete_if do |key, val|
  447       if val
  448         # a value ending in @ allows document to override value
  449         if ::String === val && (val.end_with? '@')
  450           val, verdict = val.chop, true
  451         end
  452         attrs[key] = val
  453       else
  454         # a nil or false value both unset the attribute; only a nil value locks it
  455         attrs.delete key
  456         verdict = val == false
  457       end
  458       verdict
  459     end
  460 
  461     if parent_doc
  462       @backend = attrs['backend']
  463       # reset doctype unless it matches the default value
  464       unless (@doctype = attrs['doctype'] = parent_doctype) == DEFAULT_DOCTYPE
  465         update_doctype_attributes DEFAULT_DOCTYPE
  466       end
  467 
  468       # don't need to do the extra processing within our own document
  469       # FIXME line info isn't reported correctly within include files in nested document
  470       @reader = Reader.new data, options[:cursor]
  471       @source_location = @reader.cursor if @sourcemap
  472 
  473       # Now parse the lines in the reader into blocks
  474       # Eagerly parse (for now) since a subdocument is not a publicly accessible object
  475       Parser.parse @reader, self
  476 
  477       # should we call some sort of post-parse function?
  478       restore_attributes
  479       @parsed = true
  480     else
  481       # setup default backend and doctype
  482       @backend = nil
  483       if (initial_backend = attrs['backend'] || DEFAULT_BACKEND) == 'manpage'
  484         @doctype = attrs['doctype'] = attr_overrides['doctype'] = 'manpage'
  485       else
  486         @doctype = (attrs['doctype'] ||= DEFAULT_DOCTYPE)
  487       end
  488       update_backend_attributes initial_backend, true
  489 
  490       # dynamic intrinstic attribute values
  491 
  492       #attrs['indir'] = attrs['docdir']
  493       #attrs['infile'] = attrs['docfile']
  494 
  495       # fallback directories
  496       attrs['stylesdir'] ||= '.'
  497       attrs['iconsdir'] ||= %(#{attrs.fetch 'imagesdir', './images'}/icons)
  498 
  499       fill_datetime_attributes attrs, input_mtime
  500 
  501       if initialize_extensions
  502         if (ext_registry = options[:extension_registry])
  503           # QUESTION should we warn if the value type of this option is not a registry
  504           if Extensions::Registry === ext_registry || ((defined? ::AsciidoctorJ::Extensions::ExtensionRegistry) &&
  505               ::AsciidoctorJ::Extensions::ExtensionRegistry === ext_registry)
  506             @extensions = ext_registry.activate self
  507           end
  508         elsif ::Proc === (ext_block = options[:extensions])
  509           @extensions = Extensions.create(&ext_block).activate self
  510         elsif !Extensions.groups.empty?
  511           @extensions = Extensions::Registry.new.activate self
  512         end
  513       end
  514 
  515       @reader = PreprocessorReader.new self, data, (Reader::Cursor.new attrs['docfile'], @base_dir), normalize: true
  516       @source_location = @reader.cursor if @sourcemap
  517     end
  518   end
  519 
  520   # Public: Parse the AsciiDoc source stored in the {Reader} into an abstract syntax tree.
  521   #
  522   # If the data parameter is not nil, create a new {PreprocessorReader} and assigned it to the reader
  523   # property of this object. Otherwise, continue with the reader that was created in {#initialize}.
  524   # Pass the reader to {Parser.parse} to parse the source data into an abstract syntax tree.
  525   #
  526   # If parsing has already been performed, this method returns without performing any processing.
  527   #
  528   # data - The optional replacement AsciiDoc source data as a String or String Array. (default: nil)
  529   #
  530   # Returns this [Document]
  531   def parse data = nil
  532     if @parsed
  533       self
  534     else
  535       doc = self
  536       # create reader if data is provided (used when data is not known at the time the Document object is created)
  537       if data
  538         @reader = PreprocessorReader.new doc, data, (Reader::Cursor.new @attributes['docfile'], @base_dir), normalize: true
  539         @source_location = @reader.cursor if @sourcemap
  540       end
  541 
  542       if (exts = @parent_document ? nil : @extensions) && exts.preprocessors?
  543         exts.preprocessors.each do |ext|
  544           @reader = ext.process_method[doc, @reader] || @reader
  545         end
  546       end
  547 
  548       # Now parse the lines in the reader into blocks
  549       Parser.parse @reader, doc, header_only: @options[:parse_header_only]
  550 
  551       # should we call sort of post-parse function?
  552       restore_attributes
  553 
  554       if exts && exts.tree_processors?
  555         exts.tree_processors.each do |ext|
  556           if (result = ext.process_method[doc]) && Document === result && result != doc
  557             doc = result
  558           end
  559         end
  560       end
  561 
  562       @parsed = true
  563       doc
  564     end
  565   end
  566 
  567   # Public: Returns whether the source lines of the document have been parsed.
  568   def parsed?
  569     @parsed
  570   end
  571 
  572   # Public: Get the named counter and take the next number in the sequence.
  573   #
  574   # name  - the String name of the counter
  575   # seed  - the initial value as a String or Integer
  576   #
  577   # returns the next number in the sequence for the specified counter
  578   def counter name, seed = nil
  579     return @parent_document.counter name, seed if @parent_document
  580     if (attr_seed = !(attr_val = @attributes[name]).nil_or_empty?) && (@counters.key? name)
  581       @attributes[name] = @counters[name] = Helpers.nextval attr_val
  582     elsif seed
  583       @attributes[name] = @counters[name] = seed == seed.to_i.to_s ? seed.to_i : seed
  584     else
  585       @attributes[name] = @counters[name] = Helpers.nextval attr_seed ? attr_val : 0
  586     end
  587   end
  588 
  589   # Public: Increment the specified counter and store it in the block's attributes
  590   #
  591   # counter_name - the String name of the counter attribute
  592   # block        - the Block on which to save the counter
  593   #
  594   # returns the next number in the sequence for the specified counter
  595   def increment_and_store_counter counter_name, block
  596     ((AttributeEntry.new counter_name, (counter counter_name)).save_to block.attributes).value
  597   end
  598   # Deprecated: Map old counter_increment method to increment_counter for backwards compatibility
  599   alias counter_increment increment_and_store_counter
  600 
  601   # Public: Register a reference in the document catalog
  602   def register type, value
  603     case type
  604     when :ids # deprecated
  605       register :refs, [(id = value[0]), (Inline.new self, :anchor, value[1], type: :ref, id: id)]
  606     when :refs
  607       @catalog[:refs][value[0]] ||= (ref = value[1])
  608       ref
  609     when :footnotes
  610       @catalog[type] << value
  611     else
  612       @catalog[type] << (type == :images ? (ImageReference.new value, @attributes['imagesdir']) : value) if @options[:catalog_assets]
  613     end
  614   end
  615 
  616   # Public: Scan registered references and return the ID of the first reference that matches the specified reference text.
  617   #
  618   # text - The String reference text to compare to the converted reference text of each registered reference.
  619   #
  620   # Returns the String ID of the first reference with matching reference text or nothing if no reference is found.
  621   def resolve_id text
  622     if @reftexts
  623       @reftexts[text]
  624     elsif @parsed
  625       # @reftexts is set eagerly to prevent nested lazy init
  626       (@reftexts = {}).tap {|accum| @catalog[:refs].each {|id, ref| accum[ref.xreftext] ||= id } }[text]
  627     else
  628       # @reftexts is set eagerly to prevent nested lazy init
  629       resolved_id = nil
  630       # NOTE short-circuit early since we're throwing away this table
  631       (@reftexts = {}).tap {|accum| @catalog[:refs].each {|id, ref| (xreftext = ref.xreftext) == text ? (break (resolved_id = id)) : (accum[xreftext] ||= id) } }
  632       @reftexts = nil
  633       resolved_id
  634     end
  635   end
  636 
  637   def footnotes?
  638     @catalog[:footnotes].empty? ? false : true
  639   end
  640 
  641   def footnotes
  642     @catalog[:footnotes]
  643   end
  644 
  645   def callouts
  646     @catalog[:callouts]
  647   end
  648 
  649   def nested?
  650     @parent_document ? true : false
  651   end
  652 
  653   def embedded?
  654     @attributes.key? 'embedded'
  655   end
  656 
  657   def extensions?
  658     @extensions ? true : false
  659   end
  660 
  661   # Make the raw source for the Document available.
  662   def source
  663     @reader.source if @reader
  664   end
  665 
  666   # Make the raw source lines for the Document available.
  667   def source_lines
  668     @reader.source_lines if @reader
  669   end
  670 
  671   def basebackend? base
  672     @attributes['basebackend'] == base
  673   end
  674 
  675   # Public: Return the doctitle as a String
  676   #
  677   # Returns the resolved doctitle as a [String] or nil if a doctitle cannot be resolved
  678   def title
  679     doctitle
  680   end
  681 
  682   # Public: Set the title on the document header
  683   #
  684   # Set the title of the document header to the specified value. If the header
  685   # does not exist, it is first created.
  686   #
  687   # title - the String title to assign as the title of the document header
  688   #
  689   # Returns the new [String] title assigned to the document header
  690   def title= title
  691     unless (sect = @header)
  692       (sect = (@header = Section.new self, 0)).sectname = 'header'
  693     end
  694     sect.title = title
  695   end
  696 
  697   # Public: Resolves the primary title for the document
  698   #
  699   # Searches the locations to find the first non-empty
  700   # value:
  701   #
  702   #  * document-level attribute named title
  703   #  * header title (known as the document title)
  704   #  * title of the first section
  705   #  * document-level attribute named untitled-label (if :use_fallback option is set)
  706   #
  707   # If no value can be resolved, nil is returned.
  708   #
  709   # If the :partition attribute is specified, the value is parsed into an Document::Title object.
  710   # If the :sanitize attribute is specified, XML elements are removed from the value.
  711   #
  712   # TODO separate sanitization by type (:cdata for HTML/XML, :plain_text for non-SGML, false for none)
  713   #
  714   # Returns the resolved title as a [Title] if the :partition option is passed or a [String] if not
  715   # or nil if no value can be resolved.
  716   def doctitle opts = {}
  717     unless (val = @attributes['title'])
  718       if (sect = first_section)
  719         val = sect.title
  720       elsif !(opts[:use_fallback] && (val = @attributes['untitled-label']))
  721         return
  722       end
  723     end
  724 
  725     if (separator = opts[:partition])
  726       Title.new val, opts.merge({ separator: (separator == true ? @attributes['title-separator'] : separator) })
  727     elsif opts[:sanitize] && val.include?('<')
  728       val.gsub(XmlSanitizeRx, '').squeeze(' ').strip
  729     else
  730       val
  731     end
  732   end
  733   alias name doctitle
  734 
  735   def xreftext xrefstyle = nil
  736     (val = reftext) && !val.empty? ? val : title
  737   end
  738 
  739   # Public: Convenience method to retrieve the document attribute 'author'
  740   #
  741   # returns the full name of the author as a String
  742   def author
  743     @attributes['author']
  744   end
  745 
  746   # Public: Convenience method to retrieve the authors of this document as an Array of Author objects.
  747   #
  748   # This method is backed by the author-related attributes on the document.
  749   #
  750   # returns the authors of this document as an Array
  751   def authors
  752     if (attrs = @attributes).key? 'author'
  753       authors = [(Author.new attrs['author'], attrs['firstname'], attrs['middlename'], attrs['lastname'], attrs['authorinitials'], attrs['email'])]
  754       if (num_authors = attrs['authorcount'] || 0) > 1
  755         idx = 1
  756         while idx < num_authors
  757           idx += 1
  758           authors << (Author.new attrs[%(author_#{idx})], attrs[%(firstname_#{idx})], attrs[%(middlename_#{idx})], attrs[%(lastname_#{idx})], attrs[%(authorinitials_#{idx})], attrs[%(email_#{idx})])
  759         end
  760       end
  761       authors
  762     else
  763       []
  764     end
  765   end
  766 
  767   # Public: Convenience method to retrieve the document attribute 'revdate'
  768   #
  769   # returns the date of last revision for the document as a String
  770   def revdate
  771     @attributes['revdate']
  772   end
  773 
  774   def notitle
  775     !@attributes.key?('showtitle') && @attributes.key?('notitle')
  776   end
  777 
  778   def noheader
  779     @attributes.key? 'noheader'
  780   end
  781 
  782   def nofooter
  783     @attributes.key? 'nofooter'
  784   end
  785 
  786   def first_section
  787     @header || @blocks.find {|e| e.context == :section }
  788   end
  789 
  790   def header?
  791     @header ? true : false
  792   end
  793   alias has_header? header?
  794 
  795   # Public: Append a content Block to this Document.
  796   #
  797   # If the child block is a Section, assign an index to it.
  798   #
  799   # block - The child Block to append to this parent Block
  800   #
  801   # Returns The parent Block
  802   def << block
  803     assign_numeral block if block.context == :section
  804     super
  805   end
  806 
  807   # Internal: Called by the parser after parsing the header and before parsing
  808   # the body, even if no header is found.
  809   #--
  810   # QUESTION should we invoke the TreeProcessors here, passing in a phase?
  811   # QUESTION is finalize_header the right name?
  812   def finalize_header unrooted_attributes, header_valid = true
  813     clear_playback_attributes unrooted_attributes
  814     save_attributes
  815     unrooted_attributes['invalid-header'] = true unless header_valid
  816     unrooted_attributes
  817   end
  818 
  819   # Public: Replay attribute assignments at the block level
  820   def playback_attributes(block_attributes)
  821     if block_attributes.key? :attribute_entries
  822       block_attributes[:attribute_entries].each do |entry|
  823         name = entry.name
  824         if entry.negate
  825           @attributes.delete name
  826           @compat_mode = false if name == 'compat-mode'
  827         else
  828           @attributes[name] = entry.value
  829           @compat_mode = true if name == 'compat-mode'
  830         end
  831       end
  832     end
  833   end
  834 
  835   # Public: Restore the attributes to the previously saved state (attributes in header)
  836   def restore_attributes
  837     @catalog[:callouts].rewind unless @parent_document
  838     @attributes.replace @header_attributes
  839   end
  840 
  841   # Public: Set the specified attribute on the document if the name is not locked
  842   #
  843   # If the attribute is locked, false is returned. Otherwise, the value is
  844   # assigned to the attribute name after first performing attribute
  845   # substitutions on the value. If the attribute name is 'backend' or
  846   # 'doctype', then the value of backend-related attributes are updated.
  847   #
  848   # name  - the String attribute name
  849   # value - the String attribute value; must not be nil (optional, default: '')
  850   #
  851   # Returns the substituted value if the attribute was set or nil if it was not because it's locked.
  852   def set_attribute name, value = ''
  853     unless attribute_locked? name
  854       value = apply_attribute_value_subs value unless value.empty?
  855       # NOTE if @header_attributes is set, we're beyond the document header
  856       if @header_attributes
  857         @attributes[name] = value
  858       else
  859         case name
  860         when 'backend'
  861           update_backend_attributes value, (@attributes_modified.delete? 'htmlsyntax') && value == @backend
  862         when 'doctype'
  863           update_doctype_attributes value
  864         else
  865           @attributes[name] = value
  866         end
  867         @attributes_modified << name
  868       end
  869       value
  870     end
  871   end
  872 
  873   # Public: Delete the specified attribute from the document if the name is not locked
  874   #
  875   # If the attribute is locked, false is returned. Otherwise, the attribute is deleted.
  876   #
  877   # name  - the String attribute name
  878   #
  879   # returns true if the attribute was deleted, false if it was not because it's locked
  880   def delete_attribute(name)
  881     if attribute_locked?(name)
  882       false
  883     else
  884       @attributes.delete(name)
  885       @attributes_modified << name
  886       true
  887     end
  888   end
  889 
  890   # Public: Determine if the attribute has been locked by being assigned in document options
  891   #
  892   # key - The attribute key to check
  893   #
  894   # Returns true if the attribute is locked, false otherwise
  895   def attribute_locked?(name)
  896     @attribute_overrides.key?(name)
  897   end
  898 
  899   # Public: Assign a value to the specified attribute in the document header.
  900   #
  901   # The assignment will be visible when the header attributes are restored,
  902   # typically between processor phases (e.g., between parse and convert).
  903   #
  904   # name      - The String attribute name to assign
  905   # value     - The Object value to assign to the attribute (default: '')
  906   # overwrite - A Boolean indicating whether to assign the attribute
  907   #             if already present in the attributes Hash (default: true)
  908   #
  909   # Returns a [Boolean] indicating whether the assignment was performed
  910   def set_header_attribute name, value = '', overwrite = true
  911     attrs = @header_attributes || @attributes
  912     if overwrite == false && (attrs.key? name)
  913       false
  914     else
  915       attrs[name] = value
  916       true
  917     end
  918   end
  919 
  920   # Public: Convert the AsciiDoc document using the templates
  921   # loaded by the Converter. If a :template_dir is not specified,
  922   # or a template is missing, the converter will fall back to
  923   # using the appropriate built-in template.
  924   def convert opts = {}
  925     @timings.start :convert if @timings
  926     parse unless @parsed
  927     unless @safe >= SafeMode::SERVER || opts.empty?
  928       # QUESTION should we store these on the Document object?
  929       @attributes.delete 'outfile' unless (@attributes['outfile'] = opts['outfile'])
  930       @attributes.delete 'outdir' unless (@attributes['outdir'] = opts['outdir'])
  931     end
  932 
  933     # QUESTION should we add extensions that execute before conversion begins?
  934 
  935     if doctype == 'inline'
  936       if (block = @blocks[0] || @header)
  937         if block.content_model == :compound || block.content_model == :empty
  938           logger.warn 'no inline candidate; use the inline doctype to convert a single paragragh, verbatim, or raw block'
  939         else
  940           output = block.content
  941         end
  942       end
  943     else
  944       if opts.key? :standalone
  945         transform = opts[:standalone] ? 'document' : 'embedded'
  946       elsif opts.key? :header_footer
  947         transform = opts[:header_footer] ? 'document' : 'embedded'
  948       else
  949         transform = @options[:standalone] ? 'document' : 'embedded'
  950       end
  951       output = @converter.convert self, transform
  952     end
  953 
  954     unless @parent_document
  955       if (exts = @extensions) && exts.postprocessors?
  956         exts.postprocessors.each do |ext|
  957           output = ext.process_method[self, output]
  958         end
  959       end
  960     end
  961 
  962     @timings.record :convert if @timings
  963     output
  964   end
  965 
  966   # Deprecated: Use {Document#convert} instead.
  967   alias render convert
  968 
  969   # Public: Write the output to the specified file
  970   #
  971   # If the converter responds to :write, delegate the work of writing the file
  972   # to that method. Otherwise, write the output the specified file.
  973   #
  974   # Returns nothing
  975   def write output, target
  976     @timings.start :write if @timings
  977     if Writer === @converter
  978       @converter.write output, target
  979     else
  980       if target.respond_to? :write
  981         # QUESTION should we set encoding using target.set_encoding?
  982         unless output.nil_or_empty?
  983           target.write output.chomp
  984           # ensure there's a trailing endline
  985           target.write LF
  986         end
  987       else
  988         ::File.write target, output, mode: FILE_WRITE_MODE
  989       end
  990       if @backend == 'manpage' && ::String === target && (@converter.class.respond_to? :write_alternate_pages)
  991         @converter.class.write_alternate_pages @attributes['mannames'], @attributes['manvolnum'], target
  992       end
  993     end
  994     @timings.record :write if @timings
  995     nil
  996   end
  997 
  998 =begin
  999   def convert_to target, opts = {}
 1000     start = ::Time.now.to_f if (monitor = opts[:monitor])
 1001     output = (r = converter opts).convert
 1002     monitor[:convert] = ::Time.now.to_f - start if monitor
 1003 
 1004     unless target.respond_to? :write
 1005       @attributes['outfile'] = target = ::File.expand_path target
 1006       @attributes['outdir'] = ::File.dirname target
 1007     end
 1008 
 1009     start = ::Time.now.to_f if monitor
 1010     r.write output, target
 1011     monitor[:write] = ::Time.now.to_f - start if monitor
 1012 
 1013     output
 1014   end
 1015 =end
 1016 
 1017   def content
 1018     # NOTE per AsciiDoc-spec, remove the title before converting the body
 1019     @attributes.delete('title')
 1020     super
 1021   end
 1022 
 1023   # Public: Read the docinfo file(s) for inclusion in the document template
 1024   #
 1025   # If the docinfo1 attribute is set, read the docinfo.ext file. If the docinfo
 1026   # attribute is set, read the doc-name.docinfo.ext file. If the docinfo2
 1027   # attribute is set, read both files in that order.
 1028   #
 1029   # location - The Symbol location of the docinfo (e.g., :head, :footer, etc). (default: :head)
 1030   # suffix   - The suffix of the docinfo file(s). If not set, the extension
 1031   #            will be set to the outfilesuffix. (default: nil)
 1032   #
 1033   # returns The contents of the docinfo file(s) or empty string if no files are
 1034   # found or the safe mode is secure or greater.
 1035   def docinfo location = :head, suffix = nil
 1036     if safe < SafeMode::SECURE
 1037       qualifier = %(-#{location}) unless location == :head
 1038       suffix = @outfilesuffix unless suffix
 1039 
 1040       if (docinfo = @attributes['docinfo']).nil_or_empty?
 1041         if @attributes.key? 'docinfo2'
 1042           docinfo = ['private', 'shared']
 1043         elsif @attributes.key? 'docinfo1'
 1044           docinfo = ['shared']
 1045         else
 1046           docinfo = docinfo ? ['private'] : nil
 1047         end
 1048       else
 1049         docinfo = docinfo.split(',').map {|it| it.strip }
 1050       end
 1051 
 1052       if docinfo
 1053         content = []
 1054         docinfo_file, docinfo_dir, docinfo_subs = %(docinfo#{qualifier}#{suffix}), @attributes['docinfodir'], resolve_docinfo_subs
 1055         unless (docinfo & ['shared', %(shared-#{location})]).empty?
 1056           docinfo_path = normalize_system_path docinfo_file, docinfo_dir
 1057           # NOTE normalizing the lines is essential if we're performing substitutions
 1058           if (shared_docinfo = read_asset docinfo_path, normalize: true)
 1059             content << (apply_subs shared_docinfo, docinfo_subs)
 1060           end
 1061         end
 1062 
 1063         unless @attributes['docname'].nil_or_empty? || (docinfo & ['private', %(private-#{location})]).empty?
 1064           docinfo_path = normalize_system_path %(#{@attributes['docname']}-#{docinfo_file}), docinfo_dir
 1065           # NOTE normalizing the lines is essential if we're performing substitutions
 1066           if (private_docinfo = read_asset docinfo_path, normalize: true)
 1067             content << (apply_subs private_docinfo, docinfo_subs)
 1068           end
 1069         end
 1070       end
 1071     end
 1072 
 1073     # TODO allow document to control whether extension docinfo is contributed
 1074     if @extensions && (docinfo_processors? location)
 1075       ((content || []).concat @docinfo_processor_extensions[location].map {|ext| ext.process_method[self] }.compact).join LF
 1076     elsif content
 1077       content.join LF
 1078     else
 1079       ''
 1080     end
 1081   end
 1082 
 1083   def docinfo_processors?(location = :head)
 1084     if @docinfo_processor_extensions.key?(location)
 1085       # false means we already performed a lookup and didn't find any
 1086       @docinfo_processor_extensions[location] != false
 1087     elsif @extensions && @document.extensions.docinfo_processors?(location)
 1088       !!(@docinfo_processor_extensions[location] = @document.extensions.docinfo_processors(location))
 1089     else
 1090       @docinfo_processor_extensions[location] = false
 1091     end
 1092   end
 1093 
 1094   def to_s
 1095     %(#<#{self.class}@#{object_id} {doctype: #{doctype.inspect}, doctitle: #{(@header != nil ? @header.title : nil).inspect}, blocks: #{@blocks.size}}>)
 1096   end
 1097 
 1098   private
 1099 
 1100   # Internal: Apply substitutions to the attribute value
 1101   #
 1102   # If the value is an inline passthrough macro (e.g., pass:<subs>[value]),
 1103   # apply the substitutions defined in <subs> to the value, or leave the value
 1104   # unmodified if no substitutions are specified.  If the value is not an
 1105   # inline passthrough macro, apply header substitutions to the value.
 1106   #
 1107   # value - The String attribute value on which to perform substitutions
 1108   #
 1109   # Returns The String value with substitutions performed
 1110   def apply_attribute_value_subs value
 1111     if AttributeEntryPassMacroRx =~ value
 1112       value = $2
 1113       value = apply_subs value, (resolve_pass_subs $1) if $1
 1114     else
 1115       value = apply_header_subs value
 1116     end
 1117     @max_attribute_value_size ? (limit_bytesize value, @max_attribute_value_size) : value
 1118   end
 1119 
 1120   # Internal: Safely truncates a string to the specified number of bytes.
 1121   #
 1122   # If a multibyte char gets split, the dangling fragment is dropped.
 1123   #
 1124   # str - The String the truncate.
 1125   # max - The maximum allowable size of the String, in bytes.
 1126   #
 1127   # Returns the String truncated to the specified bytesize.
 1128   def limit_bytesize str, max
 1129     if str.bytesize > max
 1130       max -= 1 until (str = str.byteslice 0, max).valid_encoding?
 1131     end
 1132     str
 1133   end
 1134 
 1135   # Internal: Resolve the list of comma-delimited subs to apply to docinfo files.
 1136   #
 1137   # Resolve the list of substitutions from the value of the docinfosubs
 1138   # document attribute, if specified. Otherwise, return an Array containing
 1139   # the Symbol :attributes.
 1140   #
 1141   # Returns an [Array] of substitution [Symbol]s
 1142   def resolve_docinfo_subs
 1143     (@attributes.key? 'docinfosubs') ? (resolve_subs @attributes['docinfosubs'], :block, nil, 'docinfo') : [:attributes]
 1144   end
 1145 
 1146   # Internal: Create and initialize an instance of the converter for this document
 1147   #--
 1148   # QUESTION is there any additional information we should be passing to the converter?
 1149   def create_converter backend, delegate_backend
 1150     converter_opts = { document: self, htmlsyntax: @attributes['htmlsyntax'] }
 1151     if (template_dirs = (opts = @options)[:template_dirs] || opts[:template_dir])
 1152       converter_opts[:template_dirs] = [*template_dirs]
 1153       converter_opts[:template_cache] = opts.fetch :template_cache, true
 1154       converter_opts[:template_engine] = opts[:template_engine]
 1155       converter_opts[:template_engine_options] = opts[:template_engine_options]
 1156       converter_opts[:eruby] = opts[:eruby]
 1157       converter_opts[:safe] = @safe
 1158       converter_opts[:delegate_backend] = delegate_backend if delegate_backend
 1159     end
 1160     if (converter = opts[:converter])
 1161       (Converter::CustomFactory.new backend => converter).create backend, converter_opts
 1162     else
 1163       (opts.fetch :converter_factory, Converter).create backend, converter_opts
 1164     end
 1165   end
 1166 
 1167   # Internal: Delete any attributes stored for playback
 1168   def clear_playback_attributes(attributes)
 1169     attributes.delete(:attribute_entries)
 1170   end
 1171 
 1172   # Internal: Branch the attributes so that the original state can be restored
 1173   # at a future time.
 1174   #
 1175   # Returns the duplicated attributes, which will later be restored
 1176   def save_attributes
 1177     unless ((attrs = @attributes).key? 'doctitle') || !(doctitle_val = doctitle)
 1178       attrs['doctitle'] = doctitle_val
 1179     end
 1180 
 1181     # css-signature cannot be updated after header attributes are processed
 1182     @id ||= attrs['css-signature']
 1183 
 1184     if (toc_val = (attrs.delete 'toc2') ? 'left' : attrs['toc'])
 1185       # toc-placement allows us to separate position from using fitted slot vs macro
 1186       toc_position_val = (toc_placement_val = attrs.fetch 'toc-placement', 'macro') && toc_placement_val != 'auto' ? toc_placement_val : attrs['toc-position']
 1187       unless toc_val.empty? && toc_position_val.nil_or_empty?
 1188         default_toc_position = 'left'
 1189         # TODO rename toc2 to aside-toc
 1190         default_toc_class = 'toc2'
 1191         position = toc_position_val.nil_or_empty? ? (toc_val.empty? ? default_toc_position : toc_val) : toc_position_val
 1192         attrs['toc'] = ''
 1193         attrs['toc-placement'] = 'auto'
 1194         case position
 1195         when 'left', '<', '&lt;'
 1196           attrs['toc-position'] = 'left'
 1197         when 'right', '>', '&gt;'
 1198           attrs['toc-position'] = 'right'
 1199         when 'top', '^'
 1200           attrs['toc-position'] = 'top'
 1201         when 'bottom', 'v'
 1202           attrs['toc-position'] = 'bottom'
 1203         when 'preamble', 'macro'
 1204           attrs['toc-position'] = 'content'
 1205           attrs['toc-placement'] = position
 1206           default_toc_class = nil
 1207         else
 1208           attrs.delete 'toc-position'
 1209           default_toc_class = nil
 1210         end
 1211         attrs['toc-class'] ||= default_toc_class if default_toc_class
 1212       end
 1213     end
 1214 
 1215     if (icons_val = attrs['icons']) && !(attrs.key? 'icontype')
 1216       case icons_val
 1217       when '', 'font'
 1218       else
 1219         attrs['icons'] = ''
 1220         attrs['icontype'] = icons_val unless icons_val == 'image'
 1221       end
 1222     end
 1223 
 1224     if (@compat_mode = attrs.key? 'compat-mode')
 1225       attrs['source-language'] = attrs['language'] if attrs.key? 'language'
 1226     end
 1227 
 1228     unless @parent_document
 1229       if (basebackend = attrs['basebackend']) == 'html'
 1230         # QUESTION should we allow source-highlighter to be disabled in AsciiDoc table cell?
 1231         if (syntax_hl_name = attrs['source-highlighter']) && !attrs[%(#{syntax_hl_name}-unavailable)]
 1232           if (syntax_hl_factory = @options[:syntax_highlighter_factory])
 1233             @syntax_highlighter = syntax_hl_factory.create syntax_hl_name, @backend, document: self
 1234           elsif (syntax_hls = @options[:syntax_highlighters])
 1235             @syntax_highlighter = (SyntaxHighlighter::DefaultFactoryProxy.new syntax_hls).create syntax_hl_name, @backend, document: self
 1236           else
 1237             @syntax_highlighter = SyntaxHighlighter.create syntax_hl_name, @backend, document: self
 1238           end
 1239         end
 1240       # enable toc and sectnums (i.e., numbered) by default in DocBook backend
 1241       elsif basebackend == 'docbook'
 1242         # NOTE the attributes_modified should go away once we have a proper attribute storage & tracking facility
 1243         attrs['toc'] = '' unless (attribute_locked? 'toc') || (@attributes_modified.include? 'toc')
 1244         attrs['sectnums'] = '' unless (attribute_locked? 'sectnums') || (@attributes_modified.include? 'sectnums')
 1245       end
 1246 
 1247       # NOTE pin the outfilesuffix after the header is parsed
 1248       @outfilesuffix = attrs['outfilesuffix']
 1249 
 1250       # unfreeze "flexible" attributes
 1251       FLEXIBLE_ATTRIBUTES.each do |name|
 1252         # turning a flexible attribute off should be permanent
 1253         # (we may need more config if that's not always the case)
 1254         if @attribute_overrides.key?(name) && @attribute_overrides[name]
 1255           @attribute_overrides.delete(name)
 1256         end
 1257       end
 1258     end
 1259 
 1260     @header_attributes = attrs.merge
 1261   end
 1262 
 1263   # Internal: Assign the local and document datetime attributes, which includes localdate, localyear, localtime,
 1264   # localdatetime, docdate, docyear, doctime, and docdatetime. Honor the SOURCE_DATE_EPOCH environment variable, if set.
 1265   def fill_datetime_attributes attrs, input_mtime
 1266     # See https://reproducible-builds.org/specs/source-date-epoch/
 1267     now = (::ENV.key? 'SOURCE_DATE_EPOCH') ? (source_date_epoch = (::Time.at Integer ::ENV['SOURCE_DATE_EPOCH']).utc) : ::Time.now
 1268     if (localdate = attrs['localdate'])
 1269       attrs['localyear'] ||= (localdate.index '-') == 4 ? (localdate.slice 0, 4) : nil
 1270     else
 1271       localdate = attrs['localdate'] = now.strftime '%F'
 1272       attrs['localyear'] ||= now.year.to_s
 1273     end
 1274     # %Z is OS dependent and may contain characters that aren't UTF-8 encoded (see asciidoctor#2770 and asciidoctor.js#23)
 1275     localtime = (attrs['localtime'] ||= now.strftime %(%T #{now.utc_offset == 0 ? 'UTC' : '%z'}))
 1276     attrs['localdatetime'] ||= %(#{localdate} #{localtime})
 1277     # docdate, doctime and docdatetime should default to localdate, localtime and localdatetime if not otherwise set
 1278     input_mtime = source_date_epoch || input_mtime || now
 1279     if (docdate = attrs['docdate'])
 1280       attrs['docyear'] ||= ((docdate.index '-') == 4 ? (docdate.slice 0, 4) : nil)
 1281     else
 1282       docdate = attrs['docdate'] = input_mtime.strftime '%F'
 1283       attrs['docyear'] ||= input_mtime.year.to_s
 1284     end
 1285     # %Z is OS dependent and may contain characters that aren't UTF-8 encoded (see asciidoctor#2770 and asciidoctor.js#23)
 1286     doctime = (attrs['doctime'] ||= input_mtime.strftime %(%T #{input_mtime.utc_offset == 0 ? 'UTC' : '%z'}))
 1287     attrs['docdatetime'] ||= %(#{docdate} #{doctime})
 1288     nil
 1289   end
 1290 
 1291   # Internal: Update the backend attributes to reflect a change in the active backend.
 1292   #
 1293   # This method also handles updating the related doctype attributes if the
 1294   # doctype attribute is assigned at the time this method is called.
 1295   #
 1296   # Returns the resolved String backend if updated, nothing otherwise.
 1297   def update_backend_attributes new_backend, init = nil
 1298     if init || new_backend != @backend
 1299       current_backend = @backend
 1300       current_basebackend = (attrs = @attributes)['basebackend']
 1301       current_doctype = @doctype
 1302       actual_backend, _, new_backend = new_backend.partition ':' if new_backend.include? ':'
 1303       if new_backend.start_with? 'xhtml'
 1304         attrs['htmlsyntax'] = 'xml'
 1305         new_backend = new_backend.slice 1, new_backend.length
 1306       elsif new_backend.start_with? 'html'
 1307         attrs['htmlsyntax'] ||= 'html'
 1308       end
 1309       new_backend = BACKEND_ALIASES[new_backend] || new_backend
 1310       new_backend, delegate_backend = actual_backend, new_backend if actual_backend
 1311       if current_doctype
 1312         if current_backend
 1313           attrs.delete %(backend-#{current_backend})
 1314           attrs.delete %(backend-#{current_backend}-doctype-#{current_doctype})
 1315         end
 1316         attrs[%(backend-#{new_backend}-doctype-#{current_doctype})] = ''
 1317         attrs[%(doctype-#{current_doctype})] = ''
 1318       elsif current_backend
 1319         attrs.delete %(backend-#{current_backend})
 1320       end
 1321       attrs[%(backend-#{new_backend})] = ''
 1322       # QUESTION should we defer the @backend assignment until after the converter is created?
 1323       @backend = attrs['backend'] = new_backend
 1324       # (re)initialize converter
 1325       if Converter::BackendTraits === (converter = create_converter new_backend, delegate_backend)
 1326         new_basebackend = converter.basebackend
 1327         new_filetype = converter.filetype
 1328         if (htmlsyntax = converter.htmlsyntax)
 1329           attrs['htmlsyntax'] = htmlsyntax
 1330         end
 1331         if init
 1332           attrs['outfilesuffix'] ||= converter.outfilesuffix
 1333         else
 1334           attrs['outfilesuffix'] = converter.outfilesuffix unless attribute_locked? 'outfilesuffix'
 1335         end
 1336       elsif converter
 1337         backend_traits = Converter.derive_backend_traits new_backend
 1338         new_basebackend = backend_traits[:basebackend]
 1339         new_filetype = backend_traits[:filetype]
 1340         if init
 1341           attrs['outfilesuffix'] ||= backend_traits[:outfilesuffix]
 1342         else
 1343           attrs['outfilesuffix'] = backend_traits[:outfilesuffix] unless attribute_locked? 'outfilesuffix'
 1344         end
 1345       else
 1346         # NOTE ideally we shouldn't need the converter before the converter phase, but we do
 1347         raise ::NotImplementedError, %(asciidoctor: FAILED: missing converter for backend '#{new_backend}'. Processing aborted.)
 1348       end
 1349       @converter = converter
 1350       if (current_filetype = attrs['filetype'])
 1351         attrs.delete %(filetype-#{current_filetype})
 1352       end
 1353       attrs['filetype'] = new_filetype
 1354       attrs[%(filetype-#{new_filetype})] = ''
 1355       if (page_width = DEFAULT_PAGE_WIDTHS[new_basebackend])
 1356         attrs['pagewidth'] = page_width
 1357       else
 1358         attrs.delete 'pagewidth'
 1359       end
 1360       if new_basebackend != current_basebackend
 1361         if current_doctype
 1362           if current_basebackend
 1363             attrs.delete %(basebackend-#{current_basebackend})
 1364             attrs.delete %(basebackend-#{current_basebackend}-doctype-#{current_doctype})
 1365           end
 1366           attrs[%(basebackend-#{new_basebackend}-doctype-#{current_doctype})] = ''
 1367         elsif current_basebackend
 1368           attrs.delete %(basebackend-#{current_basebackend})
 1369         end
 1370         attrs[%(basebackend-#{new_basebackend})] = ''
 1371         attrs['basebackend'] = new_basebackend
 1372       end
 1373       new_backend
 1374     end
 1375   end
 1376 
 1377   # Internal: Update the doctype and backend attributes to reflect a change in the active doctype.
 1378   #
 1379   # Returns the String doctype if updated, nothing otherwise.
 1380   def update_doctype_attributes new_doctype
 1381     if new_doctype && new_doctype != @doctype
 1382       current_backend, current_basebackend, current_doctype = @backend, (attrs = @attributes)['basebackend'], @doctype
 1383       if current_doctype
 1384         attrs.delete %(doctype-#{current_doctype})
 1385         if current_backend
 1386           attrs.delete %(backend-#{current_backend}-doctype-#{current_doctype})
 1387           attrs[%(backend-#{current_backend}-doctype-#{new_doctype})] = ''
 1388         end
 1389         if current_basebackend
 1390           attrs.delete %(basebackend-#{current_basebackend}-doctype-#{current_doctype})
 1391           attrs[%(basebackend-#{current_basebackend}-doctype-#{new_doctype})] = ''
 1392         end
 1393       else
 1394         attrs[%(backend-#{current_backend}-doctype-#{new_doctype})] = '' if current_backend
 1395         attrs[%(basebackend-#{current_basebackend}-doctype-#{new_doctype})] = '' if current_basebackend
 1396       end
 1397       attrs[%(doctype-#{new_doctype})] = ''
 1398       return @doctype = attrs['doctype'] = new_doctype
 1399     end
 1400   end
 1401 end
 1402 end