yaml-converter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/REEK ADDED
File without changes
data/RUBOCOP.md ADDED
@@ -0,0 +1,71 @@
1
+ # RuboCop Usage Guide
2
+
3
+ ## Overview
4
+
5
+ A tale of two RuboCop plugin gems.
6
+
7
+ ### RuboCop Gradual
8
+
9
+ This project uses `rubocop_gradual` instead of vanilla RuboCop for code style checking. The `rubocop_gradual` tool allows for gradual adoption of RuboCop rules by tracking violations in a lock file.
10
+
11
+ ### RuboCop LTS
12
+
13
+ This project uses `rubocop-lts` to ensure, on a best-effort basis, compatibility with Ruby >= 1.9.2.
14
+ RuboCop rules are meticulously configured by the `rubocop-lts` family of gems to ensure that a project is compatible with a specific version of Ruby. See: https://rubocop-lts.gitlab.io for more.
15
+
16
+ ## Checking RuboCop Violations
17
+
18
+ To check for RuboCop violations in this project, always use:
19
+
20
+ ```bash
21
+ bundle exec rake rubocop_gradual:check
22
+ ```
23
+
24
+ **Do not use** the standard RuboCop commands like:
25
+ - `bundle exec rubocop`
26
+ - `rubocop`
27
+
28
+ ## Understanding the Lock File
29
+
30
+ The `.rubocop_gradual.lock` file tracks all current RuboCop violations in the project. This allows the team to:
31
+
32
+ 1. Prevent new violations while gradually fixing existing ones
33
+ 2. Track progress on code style improvements
34
+ 3. Ensure CI builds don't fail due to pre-existing violations
35
+
36
+ ## Common Commands
37
+
38
+ - **Check violations**
39
+ - `bundle exec rake rubocop_gradual`
40
+ - `bundle exec rake rubocop_gradual:check`
41
+ - **(Safe) Autocorrect violations, and update lockfile if no new violations**
42
+ - `bundle exec rake rubocop_gradual:autocorrect`
43
+ - **Force update the lock file (w/o autocorrect) to match violations present in code**
44
+ - `bundle exec rake rubocop_gradual:force_update`
45
+
46
+ ## Workflow
47
+
48
+ 1. Before submitting a PR, run `bundle exec rake rubocop_gradual:autocorrect`
49
+ a. or just the default `bundle exec rake`, as autocorrection is a pre-requisite of the default task.
50
+ 2. If there are new violations, either:
51
+ - Fix them in your code
52
+ - Run `bundle exec rake rubocop_gradual:force_update` to update the lock file (only for violations you can't fix immediately)
53
+ 3. Commit the updated `.rubocop_gradual.lock` file along with your changes
54
+
55
+ ## Never add inline RuboCop disables
56
+
57
+ Do not add inline `rubocop:disable` / `rubocop:enable` comments anywhere in the codebase (including specs, except when following the few existing `rubocop:disable` patterns for a rule already being disabled elsewhere in the code). We handle exceptions in two supported ways:
58
+
59
+ - Permanent/structural exceptions: prefer adjusting the RuboCop configuration (e.g., in `.rubocop.yml`) to exclude a rule for a path or file pattern when it makes sense project-wide.
60
+ - Temporary exceptions while improving code: record the current violations in `.rubocop_gradual.lock` via the gradual workflow:
61
+ - `bundle exec rake rubocop_gradual:autocorrect` (preferred; will autocorrect what it can and update the lock only if no new violations were introduced)
62
+ - If needed, `bundle exec rake rubocop_gradual:force_update` (as a last resort when you cannot fix the newly reported violations immediately)
63
+
64
+ In general, treat the rules as guidance to follow; fix violations rather than ignore them. For example, RSpec conventions in this project expect `described_class` to be used in specs that target a specific class under test.
65
+
66
+ ## Benefits of rubocop_gradual
67
+
68
+ - Allows incremental adoption of code style rules
69
+ - Prevents CI failures due to pre-existing violations
70
+ - Provides a clear record of code style debt
71
+ - Enables focused efforts on improving code quality over time
data/SECURITY.md ADDED
@@ -0,0 +1,21 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ | Version | Supported |
6
+ |----------|-----------|
7
+ | 1.latest | ✅ |
8
+
9
+ ## Security contact information
10
+
11
+ To report a security vulnerability, please use the
12
+ [Tidelift security contact](https://tidelift.com/security).
13
+ Tidelift will coordinate the fix and disclosure.
14
+
15
+ ## Additional Support
16
+
17
+ If you are interested in support for versions older than the latest release,
18
+ please consider sponsoring the project / maintainer @ https://liberapay.com/pboling/donate,
19
+ or find other sponsorship links in the [README].
20
+
21
+ [README]: README.md
data/exe/yaml-convert ADDED
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "fileutils"
6
+ require "yaml/converter"
7
+
8
+ options = {
9
+ max_line_length: nil,
10
+ truncate: nil,
11
+ margin_notes: nil,
12
+ validate: nil,
13
+ use_pandoc: nil,
14
+ pandoc_args: nil,
15
+ glob: nil,
16
+ out_ext: nil,
17
+ out_dir: nil,
18
+ concurrency: nil,
19
+ streaming: nil,
20
+ streaming_threshold_bytes: nil,
21
+ }
22
+
23
+ parser = OptionParser.new do |opts|
24
+ opts.banner = "Usage: yaml-convert INPUT.yaml OUTPUT.(md|html|pdf|docx) [options]\n yaml-convert --glob 'dir/**/*.yaml' --out-ext (md|html|pdf|docx) [--out-dir DIR] [options]"
25
+
26
+ opts.on("--max-line-length N", Integer, "Set maximum line length (default 70)") { |v| options[:max_line_length] = v }
27
+ opts.on("--no-truncate", "Disable line truncation") { options[:truncate] = false }
28
+ opts.on("--no-validate", "Disable YAML validation") { options[:validate] = false }
29
+ opts.on("--margin-notes MODE", "Set margin notes mode (auto|inline|ignore)") { |v| options[:margin_notes] = v.to_sym }
30
+ opts.on("--use-pandoc", "Use pandoc for non-md/html output formats") { options[:use_pandoc] = true }
31
+ opts.on("--pandoc-args STRING", "Extra args for pandoc (split on spaces)") { |v| options[:pandoc_args] = v.split(/\s+/) }
32
+ opts.on("--streaming", "Force streaming mode for markdown output") { options[:streaming] = true }
33
+ opts.on("--streaming-threshold BYTES", Integer, "Auto-enable streaming when input file >= BYTES") { |v| options[:streaming_threshold_bytes] = v }
34
+
35
+ # Batch mode
36
+ opts.on("--glob PATTERN", "Batch convert all YAML files matching the glob pattern") { |v| options[:glob] = v }
37
+ opts.on("--out-ext EXT", "Output extension for batch mode (md|html|pdf|docx)") { |v| options[:out_ext] = v }
38
+ opts.on("--out-dir DIR", "Output directory for batch mode (defaults next to input)") { |v| options[:out_dir] = v }
39
+ opts.on("--concurrency N", Integer, "Batch concurrency (currently not used; reserved)") { |v| options[:concurrency] = v }
40
+
41
+ opts.on("--version", "Show version") {
42
+ puts Yaml::Converter::VERSION
43
+ exit(0)
44
+ }
45
+ opts.on("--help", "Show help") {
46
+ puts opts
47
+ exit(0)
48
+ }
49
+ end
50
+
51
+ begin
52
+ parser.parse!(ARGV)
53
+ rescue OptionParser::InvalidOption => e
54
+ warn(e.message)
55
+ puts parser
56
+ exit(2)
57
+ end
58
+
59
+ # Batch mode if --glob is provided
60
+ if options[:glob]
61
+ pattern = options[:glob]
62
+ out_ext = options[:out_ext]
63
+ unless out_ext&.match?(/^\w+$/)
64
+ warn "Error: --out-ext is required for --glob and must be like: md|html|pdf|docx"
65
+ puts parser
66
+ exit 2
67
+ end
68
+ files = Dir.glob(pattern).select { |p| File.file?(p) }
69
+ if files.empty?
70
+ warn "No files matched glob: #{pattern}"
71
+ exit 2
72
+ end
73
+ out_dir = options[:out_dir]
74
+ FileUtils.mkdir_p(out_dir) if out_dir
75
+
76
+ failures = 0
77
+ files.each do |input|
78
+ base = File.basename(input, File.extname(input))
79
+ dest_dir = out_dir || File.dirname(input)
80
+ FileUtils.mkdir_p(dest_dir)
81
+ output = File.join(dest_dir, base + "." + out_ext)
82
+ begin
83
+ result = Yaml::Converter.convert(input_path: input, output_path: output, options: options.compact)
84
+ puts "Converted: #{result[:output_path]}" unless ENV["KETTLE_TEST_SILENT"] == "true"
85
+ rescue Yaml::Converter::InvalidArgumentsError, Yaml::Converter::PandocNotFoundError, Yaml::Converter::RendererUnavailableError => e
86
+ failures += 1
87
+ warn("Failed: #{input}: #{e.message}")
88
+ rescue StandardError => e
89
+ failures += 1
90
+ warn("Unexpected error converting #{input}: #{e.class}: #{e.message}")
91
+ end
92
+ end
93
+ puts "Batch complete: #{files.size - failures} succeeded, #{failures} failed" unless ENV["KETTLE_TEST_SILENT"] == "true"
94
+ exit(failures.zero? ? 0 : 5)
95
+ end
96
+
97
+ # Single-file mode
98
+ if ARGV.length != 2
99
+ warn "Error: require INPUT.yaml and OUTPUT.<ext>"
100
+ puts parser
101
+ exit 2
102
+ end
103
+
104
+ input, output = ARGV
105
+
106
+ begin
107
+ result = Yaml::Converter.convert(input_path: input, output_path: output, options: options.compact)
108
+ puts "Converted: #{result[:output_path]}" unless ENV["KETTLE_TEST_SILENT"] == "true"
109
+ exit(0)
110
+ rescue Yaml::Converter::InvalidArgumentsError => e
111
+ warn(e.message)
112
+ exit(2)
113
+ rescue Yaml::Converter::PandocNotFoundError => e
114
+ warn(e.message)
115
+ exit(5)
116
+ rescue Yaml::Converter::RendererUnavailableError => e
117
+ warn(e.message)
118
+ exit(5)
119
+ rescue StandardError => e
120
+ warn("Unexpected error: #{e.class}: #{e.message}")
121
+ exit(9)
122
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Yaml
6
+ module Converter
7
+ # Central configuration handling: merges explicit options with ENV and defaults.
8
+ # Use {Yaml::Converter::Config.resolve} to obtain the finalized options hash.
9
+ module Config
10
+ DEFAULTS = {
11
+ max_line_length: 70,
12
+ truncate: true,
13
+ margin_notes: :auto, # :auto | :inline | :ignore
14
+ validate: true,
15
+ use_pandoc: false,
16
+ pandoc_args: ["-N", "--toc"],
17
+ pandoc_path: nil,
18
+ html_theme: :basic,
19
+ pdf_page_size: "LETTER",
20
+ pdf_margin: [36, 36, 36, 36], # top,right,bottom,left points (0.5")
21
+ pdf_title_font_size: 14,
22
+ pdf_body_font_size: 11,
23
+ pdf_yaml_font_size: 9,
24
+ pdf_two_column_notes: false,
25
+ current_date: Date.today, # allows injection for deterministic tests
26
+ emit_footer: true,
27
+ # Streaming options (Phase 3 feature now implemented):
28
+ streaming: false, # force streaming mode even for small files
29
+ streaming_threshold_bytes: 5_000_000, # auto-enable streaming for large files when not forced
30
+ }.freeze
31
+
32
+ ENV_MAP = {
33
+ max_line_length: "YAML_CONVERTER_MAX_LINE_LEN",
34
+ truncate: "YAML_CONVERTER_TRUNCATE",
35
+ margin_notes: "YAML_CONVERTER_MARGIN_NOTES",
36
+ validate: "YAML_CONVERTER_VALIDATE",
37
+ use_pandoc: "YAML_CONVERTER_USE_PANDOC",
38
+ pdf_page_size: "YAML_CONVERTER_PDF_PAGE_SIZE",
39
+ pdf_title_font_size: "YAML_CONVERTER_PDF_TITLE_FONT_SIZE",
40
+ pdf_body_font_size: "YAML_CONVERTER_PDF_BODY_FONT_SIZE",
41
+ pdf_yaml_font_size: "YAML_CONVERTER_PDF_YAML_FONT_SIZE",
42
+ pdf_two_column_notes: "YAML_CONVERTER_PDF_TWO_COLUMN_NOTES",
43
+ emit_footer: "YAML_CONVERTER_EMIT_FOOTER",
44
+ streaming: "YAML_CONVERTER_STREAMING",
45
+ streaming_threshold_bytes: "YAML_CONVERTER_STREAMING_THRESHOLD_BYTES",
46
+ }.freeze
47
+
48
+ BOOLEAN_KEYS = %i[truncate validate use_pandoc pdf_two_column_notes emit_footer streaming].freeze
49
+
50
+ class << self
51
+ # Merge caller options with environment overrides and defaults.
52
+ # Environment bools accept values: 1, true, yes, on (case-insensitive).
53
+ #
54
+ # @param options [Hash]
55
+ # @return [Hash] normalized options suitable for passing to emitters/renderers
56
+ def resolve(options = {})
57
+ opts = DEFAULTS.dup
58
+ ENV_MAP.each do |key, env_key|
59
+ val = ENV[env_key]
60
+ next if val.nil?
61
+
62
+ opts[key] = coerce_env_value(key, val)
63
+ end
64
+ options.each do |k, v|
65
+ opts[k] = v unless v.nil?
66
+ end
67
+ normalize(opts)
68
+ end
69
+
70
+ # Normalize symbolic values loaded from ENV.
71
+ # @param opts [Hash]
72
+ # @return [Hash]
73
+ def normalize(opts)
74
+ opts[:margin_notes] = opts[:margin_notes].to_sym if opts[:margin_notes].is_a?(String)
75
+ opts[:html_theme] = opts[:html_theme].to_sym if opts[:html_theme].is_a?(String)
76
+ if opts[:streaming_threshold_bytes].is_a?(String)
77
+ opts[:streaming_threshold_bytes] = opts[:streaming_threshold_bytes].to_i
78
+ end
79
+ opts
80
+ end
81
+
82
+ # Coerce ENV string values into typed Ruby objects.
83
+ # @param key [Symbol]
84
+ # @param value [String]
85
+ # @return [Object]
86
+ def coerce_env_value(key, value)
87
+ if BOOLEAN_KEYS.include?(key)
88
+ %w[1 true yes on].include?(value.to_s.downcase)
89
+ elsif key == :max_line_length || key == :streaming_threshold_bytes
90
+ value.to_i
91
+ else
92
+ value
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "parser"
5
+ require_relative "state_machine"
6
+
7
+ module Yaml
8
+ module Converter
9
+ # High-level emitter orchestrates parsing tokens and applying the state machine,
10
+ # producing a Markdown document with fenced YAML blocks and extracted notes.
11
+ class MarkdownEmitter
12
+ START_YAML = "```yaml"
13
+ END_YAML = "```"
14
+ VALIDATED_STR = "YAML validation:"
15
+ NOTE_STR = "note:"
16
+
17
+ # @param options [Hash] Configuration values merged from {Config.resolve}
18
+ def initialize(options)
19
+ @options = options
20
+ @max_len = options.fetch(:max_line_length)
21
+ @truncate = options.fetch(:truncate)
22
+ @margin_notes = options.fetch(:margin_notes)
23
+ @validate = options.fetch(:validate)
24
+ @validation_status = :ok
25
+ @is_latex = false
26
+ end
27
+
28
+ # Set the validation status used when injecting the validation status line.
29
+ # @param status [Symbol] :ok or :fail
30
+ # @return [void]
31
+ def set_validation_status(status)
32
+ @validation_status = status
33
+ end
34
+
35
+ # Convert input lines to markdown using parser + state machine, then append a footer.
36
+ # @param lines [Array<String>] Raw YAML file lines
37
+ # @return [Array<String>] Final markdown lines
38
+ def emit(lines)
39
+ parser = Parser.new(@options)
40
+ tokens = parser.tokenize(lines)
41
+ sm = StateMachine.new(validation_status: @validation_status, max_line_length: @max_len, truncate: @truncate, margin_notes: @margin_notes, current_date: @options[:current_date])
42
+ body = sm.apply(tokens)
43
+ if @options[:emit_footer]
44
+ body << "---- \n\nProduced by [yaml-converter](https://github.com/kettle-rb/yaml-converter)"
45
+ end
46
+ body
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yaml
4
+ module Converter
5
+ # Tokenizes input YAML lines (with inline annotations) into structured tokens
6
+ # consumable by the {Yaml::Converter::StateMachine}.
7
+ #
8
+ # Input assumptions:
9
+ # - Comment titles: lines starting with `# ` become title tokens.
10
+ # - Validation marker: a comment line starting with `# YAML validation:` is recognized.
11
+ # - Separator lines (`---`) are recognized and currently ignored by the state machine.
12
+ # - Inline notes: fragments after `#note:` are captured as out-of-band NOTE tokens.
13
+ # - Other non-empty lines are treated as YAML content.
14
+ class Parser
15
+ # Lightweight token structure used by the parser/state machine pipeline.
16
+ #
17
+ # @!attribute [rw] type
18
+ # @return [Symbol] One of :blank, :title, :validation, :separator, :dash_heading, :yaml_line, :note
19
+ # @!attribute [rw] text
20
+ # @return [String] Payload string for this token
21
+ # @!attribute [rw] meta
22
+ # @return [Hash,nil] Optional metadata bag (currently unused)
23
+ Token = Struct.new(:type, :text, :meta, keyword_init: true)
24
+
25
+ # Comment line prefix indicating a validation status line will be injected
26
+ VALIDATION_PREFIX = "# YAML validation:"
27
+ # Inline note marker captured from right side of a line
28
+ NOTE_MARK = "#note:"
29
+
30
+ # @param options [Hash] Reserved for future parsing options
31
+ def initialize(options = {})
32
+ @options = options
33
+ end
34
+
35
+ # Convert raw lines into token objects.
36
+ #
37
+ # @param lines [Array<String>] Input lines (including newlines)
38
+ # @return [Array<Token>] Sequence of tokens representing the document structure
39
+ def tokenize(lines)
40
+ tokens = []
41
+ lines.each do |raw|
42
+ line = raw.rstrip
43
+ if line.empty?
44
+ tokens << Token.new(type: :blank, text: "")
45
+ next
46
+ end
47
+
48
+ if line.start_with?("# ") || line == "#"
49
+ if line.start_with?(VALIDATION_PREFIX)
50
+ tokens << Token.new(type: :validation, text: line[2..])
51
+ else
52
+ content = (line == "#") ? "" : line[2..]
53
+ tokens << Token.new(type: :title, text: content)
54
+ end
55
+ next
56
+ end
57
+
58
+ if line == "---"
59
+ tokens << Token.new(type: :separator, text: line)
60
+ next
61
+ end
62
+
63
+ if line.start_with?("-")
64
+ tokens << Token.new(type: :dash_heading, text: line[1..].strip)
65
+ end
66
+
67
+ note_idx = line.index(NOTE_MARK)
68
+ if note_idx
69
+ base = line[0...note_idx].rstrip
70
+ note = line[(note_idx + NOTE_MARK.length)..].to_s.strip
71
+ tokens << Token.new(type: :yaml_line, text: base)
72
+ tokens << Token.new(type: :note, text: note)
73
+ else
74
+ tokens << Token.new(type: :yaml_line, text: line)
75
+ end
76
+ end
77
+ tokens
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Yaml
6
+ module Converter
7
+ module Renderer
8
+ # Shells out to pandoc to convert markdown to target format.
9
+ # Provides lightweight detection of pandoc and execution with arbitrary arguments.
10
+ #
11
+ # @example Convert markdown to PDF
12
+ # Yaml::Converter::Renderer::PandocShell.render(
13
+ # md_path: 'doc.md', out_path: 'doc.pdf', args: ['-N', '--toc']
14
+ # )
15
+ module PandocShell
16
+ module_function
17
+
18
+ # Locate an executable in the current PATH.
19
+ # @param cmd [String] command name, e.g. 'pandoc'
20
+ # @return [String,nil] full path if found, otherwise nil
21
+ def which(cmd)
22
+ ENV["PATH"].split(File::PATH_SEPARATOR).each do |path|
23
+ exe = File.join(path, cmd)
24
+ return exe if File.executable?(exe)
25
+ end
26
+ nil
27
+ end
28
+
29
+ # Invoke pandoc to convert markdown file to another format.
30
+ #
31
+ # @param md_path [String] path to markdown file
32
+ # @param out_path [String] desired output file path with extension
33
+ # @param pandoc_path [String,nil] override path to pandoc binary (auto-detected if nil)
34
+ # @param args [Array<String>] extra pandoc arguments (e.g. ['-N','--toc'])
35
+ # @return [Boolean] true if successful, false otherwise
36
+ # @example Basic HTML conversion
37
+ # Yaml::Converter::Renderer::PandocShell.render(
38
+ # md_path: 'in.md', out_path: 'out.html'
39
+ # )
40
+ def render(md_path:, out_path:, pandoc_path: nil, args: [])
41
+ bin = pandoc_path || which("pandoc")
42
+ return false unless bin
43
+ cmd = [bin] + args + ["-o", out_path, md_path]
44
+ _stdout, stderr, status = Open3.capture3(*cmd)
45
+ unless status.success?
46
+ warn("pandoc failed: #{stderr}")
47
+ return false
48
+ end
49
+ true
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yaml
4
+ module Converter
5
+ module Renderer
6
+ # Native PDF rendering using prawn.
7
+ # Basic layout: title lines, YAML block in monospace, notes as italic paragraphs.
8
+ #
9
+ # For PDF rendering via pandoc, see {Yaml::Converter::Renderer::PandocShell}.
10
+ module PdfPrawn
11
+ module_function
12
+
13
+ # Render a PDF document from the given markdown string.
14
+ #
15
+ # @param markdown [String] Markdown that includes fenced YAML and blockquote notes
16
+ # @param out_path [String] Destination PDF path
17
+ # @param options [Hash] PDF options (see Config defaults: page size, margins, font sizes, two-column notes)
18
+ # @option options [String] :pdf_page_size ("LETTER")
19
+ # @option options [Array<Integer>] :pdf_margin ([36,36,36,36])
20
+ # @option options [Integer] :pdf_title_font_size (14)
21
+ # @option options [Integer] :pdf_body_font_size (11)
22
+ # @option options [Integer] :pdf_yaml_font_size (9)
23
+ # @option options [Boolean] :pdf_two_column_notes (false)
24
+ # @return [Boolean] true if rendering succeeded
25
+ def render(markdown:, out_path:, options: {})
26
+ require "prawn"
27
+
28
+ notes = extract_notes(markdown)
29
+ yaml_section = fenced_yaml(markdown)
30
+ title_lines = header_lines(markdown)
31
+
32
+ page_size = options[:pdf_page_size] || "LETTER"
33
+ margin = options[:pdf_margin] || [36, 36, 36, 36]
34
+ title_fs = options[:pdf_title_font_size] || 14
35
+ body_fs = options[:pdf_body_font_size] || 11
36
+ yaml_fs = options[:pdf_yaml_font_size] || 9
37
+ two_col = !!options[:pdf_two_column_notes]
38
+
39
+ Prawn::Document.generate(out_path, page_size: page_size, margin: margin) do |pdf|
40
+ pdf.font_size(title_fs)
41
+ title_lines.each { |l| pdf.text(l, style: :bold) }
42
+ pdf.move_down(10) if title_lines.any?
43
+
44
+ if two_col && notes.any?
45
+ # Create two columns: left for YAML, right for notes
46
+ col_gap = 16
47
+ col_width = (pdf.bounds.width - col_gap) / 2.0
48
+ pdf.bounding_box([pdf.bounds.left, pdf.cursor], width: col_width, height: pdf.cursor) do
49
+ pdf.font_size(yaml_fs)
50
+ pdf.font("Courier") { yaml_section.each { |l| pdf.text(l) } }
51
+ end
52
+ pdf.bounding_box([pdf.bounds.left + col_width + col_gap, pdf.cursor], width: col_width, height: pdf.cursor) do
53
+ pdf.font_size(body_fs)
54
+ notes.each { |n| pdf.text("NOTE: #{n}", style: :italic) }
55
+ end
56
+ else
57
+ pdf.font_size(yaml_fs)
58
+ pdf.font("Courier") { yaml_section.each { |l| pdf.text(l) } }
59
+ pdf.move_down(10)
60
+ unless notes.empty?
61
+ pdf.font_size(body_fs)
62
+ notes.each { |n| pdf.text("NOTE: #{n}", style: :italic) }
63
+ end
64
+ end
65
+
66
+ pdf.number_pages("Page <page> of <total>", at: [pdf.bounds.right - 100, 0])
67
+ end
68
+ true
69
+ rescue StandardError => e
70
+ warn("prawn pdf failed: #{e.class}: #{e.message}")
71
+ false
72
+ end
73
+
74
+ # Extract leading `# Title lines`.
75
+ # @param markdown [String]
76
+ # @return [Array<String>]
77
+ def header_lines(markdown)
78
+ markdown.lines.take_while { |l| l.start_with?("# ") }.map { |l| l.sub(/^# /, "").strip }
79
+ end
80
+
81
+ # Return the lines inside the first fenced ```yaml block.
82
+ # @param markdown [String]
83
+ # @return [Array<String>]
84
+ def fenced_yaml(markdown)
85
+ inside = false
86
+ lines = []
87
+ markdown.each_line do |l|
88
+ if l.start_with?("```yaml")
89
+ inside = true
90
+ next
91
+ elsif inside && l.strip == "```"
92
+ inside = false
93
+ break
94
+ elsif inside
95
+ lines << l.rstrip
96
+ end
97
+ end
98
+ lines
99
+ end
100
+
101
+ # Extract note strings from Markdown blockquote lines.
102
+ # @param markdown [String]
103
+ # @return [Array<String>]
104
+ def extract_notes(markdown)
105
+ markdown.lines.grep(/^> NOTE:/).map { |l| l.sub(/^> NOTE:\s*/, "").strip }
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end