Class: RSyntaxTree::RSGenerator

Inherits:
Object
  • Object
show all
Defined in:
lib/rsyntaxtree.rb

Constant Summary collapse

OFF =

Two gates, because neither one alone tells the truth.

Bracket balance catches what the drawing path silently repairs: it closes an unclosed bracket and drops a stray one, so a typo would otherwise draw a tree the writer never asked for.

Parsing catches what balance cannot see: label markup — an unpaired underline in a word, a matrix left open where a raw space split a value — passes the bracket count and then fails at draw time, which is how a caller came to be told "OK" and still get an error.

Passing both means the input is well-formed and really does draw.

Options matter to the second gate: hyphen: "literal" decides whether a hyphen is an underline, so validating without the caller's options rejects input that draws. An on/off option, read the way someone writing one would mean it. This used to compare against "off" and "false" alone, so every other way of saying no switched the option on: mirror: "no" reversed the tree, transparent: "0" cut the background out, and a capital in "Off" was enough on its own.

["off", "false", "no", "none", "0", ""].freeze
NOTE_OPTIONS =
"Not every option could be read, so the input itself has " \
"not been checked yet; fixing the options may reveal more."
NOTE_STRUCTURE =
"The bracket structure could not be read, so the labels " \
"have not been checked yet; fixing it may reveal more."
NOTE_LABELS =
"Whole-tree checks (paths, output limits) run only once " \
"every label reads, so fixing these may reveal more."
NOTE_TRUNCATED =
" Only the first #{StringParser::COLLECTED_ERRORS_LIMIT} " \
"problems are listed."

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(params = {}) ⇒ RSGenerator

Returns a new instance of RSGenerator.



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
# File 'lib/rsyntaxtree.rb', line 219

def initialize(params = {})
  new_params = {}
  fontset = {}
  params.each do |keystr, value|
    key = keystr.to_sym
    # An option given as an empty string is an option not given. An HTML
    # form posts a field for every control it carries, and a control with
    # nothing selected posts the empty string — so a form that has outlived
    # one of its own controls sends `format=` and every other option along
    # with it. Read as a value, that is a choice nobody can have made, and
    # it failed the whole request: the web UI's three Download buttons
    # returned 500 for every input, with the reason nowhere the user could
    # see it. Read as silence, the default stands, which is what the sender
    # meant.
    next if (OPTION_VALUES.key?(key) || NUMERIC_RANGES.key?(key)) &&
            (value.nil? || value.to_s.strip.empty?)

    if REMOVED_OPTIONS.key?(key)
      raise RSTError.new(+"Error: option '#{key}' was removed in RSyntaxTree 2.0",
                         code: :invalid_option, label: key.to_s,
                         hint: "Use #{REMOVED_OPTIONS[key]} instead.",
                         retryable: false)
    end
    if OPTION_VALUES.key?(key) && !OPTION_VALUES[key].include?(value.to_s)
      raise RSTError.new(+"Error: invalid value for option '#{key}': #{value.inspect}",
                         code: :invalid_option, label: key.to_s,
                         hint: "'#{key}' must be one of: #{OPTION_VALUES[key].join(', ')}.",
                         retryable: false)
    end
    # The range check reads the value as a float, and a string that is
    # not a number reads as zero. Every range used to sit clear of zero,
    # so nonsense failed the range check by accident; shear's runs through
    # it, and "abc" would have been taken as no shear at all.
    if NUMERIC_RANGES.key?(key) && !value.is_a?(Numeric) &&
       value.to_s.strip !~ /\A-?(\d+(\.\d+)?|\.\d+)\z/
      raise RSTError.new(+"Error: invalid value for option '#{key}': #{value.inspect}",
                         code: :invalid_option, label: key.to_s,
                         hint: "'#{key}' takes a number.",
                         retryable: false)
    end
    if NUMERIC_RANGES.key?(key) && !NUMERIC_RANGES[key].cover?(value.to_f)
      range = NUMERIC_RANGES[key]
      raise RSTError.new(+"Error: invalid value for option '#{key}': #{value.inspect}",
                         code: :invalid_option, label: key.to_s,
                         hint: "'#{key}' must be in the range of #{range.begin}-#{range.end}.",
                         retryable: false)
    end
    case key
    when :data
      data = value
      data = data.gsub('-AMP-', '&')
                 .gsub('-PERCENT-', "%")
                 .gsub('-PRIME-', "'")
                 .gsub('-SCOLON-', ';')
                 .gsub('-OABRACKET-', '<')
                 .gsub('-CABRACKET-', '>')
                 .gsub('¥¥', '')
                 .gsub(/(?<!\\)¥/, "\\")
      # Penn Treebank input converts to bracket notation here, in the
      # library, so every path — CLI, web UI, any other caller — sees
      # the documented automatic conversion. Only '('-leading input is
      # affected; bracket notation passes through unchanged.
      new_params[key] = FormatConverter.to_bracket(data)

    when :tidy
      # One layout scale from the most spacious to the most dense:
      # "symmetric" (radical symmetrization, uniform sibling slots),
      # "off" (the traditional layout), "low" (contour packing with
      # strict leaf positions), "medium" (packing that may tuck
      # branches across rows as long as no two leaves swap their
      # left-right order), "high" (free tucking; leaf order kept per
      # row only). "on"/"compact" are accepted as aliases of low/high.
      # The separate symmetrize and tidy_nest flags this scale replaced
      # were removed in 2.0; tidy: symmetric and tidy: high say what
      # they said.
      new_params[key] = case value.to_s
                        when "high", "compact"
                          "high"
                        when "medium"
                          "medium"
                        when "low", "on", "true"
                          "low"
                        when "symmetric"
                          "symmetric"
                        else
                          "off"
                        end
    when :transparent, :polyline, :hide_default_connectors, :mirror, :derivation
      new_params[key] = switched_on?(value)
    when :color
      new_params[key] = case value.to_s
                        when "modern", "on", "true"
                          "modern"
                        when "traditional"
                          "traditional"
                        when "gray", "grey"
                          "gray"
                        else
                          "off"
                        end
    when :format
      new_params[key] = value.to_s == "lsif" ? "json" : value.to_s
    when :hyphen
      new_params[key] = value.to_s == "literal" ? "literal" : "markup"
    when :fontsize
      new_params[key] = value.to_i
    when :linewidth
      new_params[key] = value.to_f
    when :vheight, :hspacing, :shear, :vmargin
      new_params[key] = value.to_f
    when :shear_plane
      # on | off | a colour. The empty string is a form control nobody
      # touched, so the default stands — this key sits outside the value
      # tables the blanket blank-skip above covers.
      v = value.to_s.strip
      unless v.empty?
        new_params[key] = case v.downcase
                          when "on", "true", "yes", "1" then "on"
                          when "off", "false", "no", "0", "none" then "off"
                          else
                            unless COLOR_NAMES.include?(v.downcase) || v =~ /\A#(\h{3}|\h{6})\z/
                              raise RSTError.new(+"Error: invalid value for option 'shear_plane': #{value.inspect}",
                                                 code: :invalid_option, label: "shear_plane",
                                                 hint: "'shear_plane' is on, off, a colour name, " \
                                                       "or a hex colour of 3 or 6 digits.",
                                                 retryable: false)
                            end
                            v
                          end
      end
    when :fontstyle
      # Fonts are resolved by name through fontconfig (measurement via
      # Pango, rendering via the SVG font-family attribute), so all a
      # style needs is its family fallback chain.
      style = case value.to_s
              when "noto-sans-mono", "mono" then :mono
              when "noto-serif", "serif" then :serif
              when "cjk zenhei", "cjk" then :cjk
              else :sans
              end
      fontset[:family] = FontFamily.for_pango(style)
      new_params[:fontstyle] = style.to_s
    else
      new_params[key] = value
    end
  end

  # defaults to the following
  @params = DEFAULT_OPTS.dup
  @params.merge! new_params
  @params[:fontsize] = @params[:fontsize] * FONT_SCALING
  # fontset is populated above only when :fontstyle is passed explicitly;
  # fall back to the merged default style otherwise
  if fontset[:family].nil?
    fontset[:family] = FontFamily.for_pango(@params[:fontstyle])
  end
  @params[:fontset] = fontset
  single_x_metrics = FontMetrics.get_metrics("X", fontset[:family], @params[:fontsize], :normal, :normal)
  @global = {}
  # A derivation is written down the page: the rule that joins the
  # premises is a horizontal line across them, and the result sits under
  # it. Laid out left to right there is nothing for that line to span, and
  # the drawing came out with rules struck through the categories. There
  # is no left-to-right convention for a derivation to fall back on, so
  # the combination is refused rather than approximated.
  if @params[:derivation] == true && @params[:direction] == "ltr"
    raise RSTError.new(+"Error: a derivation cannot be drawn left to right",
                       code: :invalid_option, label: "derivation",
                       hint: "A derivation runs down the page. Use direction ttb or btt, " \
                             "or turn derivation off.",
                       retryable: false)
  end

  # Hiding the default connectors draws them in the background colour
  # rather than skipping them. A derivation's rules are drawn as connectors
  # but they are the figure itself, not a default the drawing adds, so
  # hiding them leaves rows of categories floating with nothing joining
  # them. Refused for the same reason as left to right.
  if @params[:derivation] == true && @params[:hide_default_connectors] == true
    raise RSTError.new(+"Error: a derivation's rules cannot be hidden",
                       code: :invalid_option, label: "derivation",
                       hint: "The rules are what a derivation is drawn with, not a " \
                             "connector added to it. Turn off hide default connectors, " \
                             "or turn derivation off.",
                       retryable: false)
  end

  @global[:single_x_metrics] = single_x_metrics
  # A derivation labels each step with the rule it applied, written at the
  # right end of the line. Elements need to know, because the name is taken
  # out of the label before it is measured.
  @global[:derivation] = @params[:derivation] == true
  @global[:height_connector_to_text] = single_x_metrics.height / 2.0
  @global[:single_line_height] = single_x_metrics.height * 2.0
  @global[:width_half_x] = single_x_metrics.width / 2.0
  @global[:height_connector] = single_x_metrics.height * @params[:vheight]
  # Horizontal counterpart of vheight: hspacing scales every horizontal
  # gap (sibling clearance in all layout modes, tidy minimum gap,
  # margins) the way vheight scales the vertical rhythm.
  #
  # The factor is kept beside the gap it scales because the left-to-right
  # layout replaces that gap with one of its own and has to scale it by
  # the same amount. Recovering the factor by dividing the gap back out
  # would be this rule written a second time, free to drift from this one.
  @global[:hspacing] = (@params[:hspacing] || 1.0).to_f
  @global[:h_gap_between_nodes] = single_x_metrics.width * 0.8 * @global[:hspacing]
  # The font's band: how far a capital reaches above the baseline and a
  # descender below it. What the symmetric clearances of vmargin measure
  # from — the band rather than each label's own ink, so the line ends
  # line up across a level instead of following every g and y.
  @global[:cap_height] = single_x_metrics.ink_above
  xg_metrics = FontMetrics.get_metrics("Xg", fontset[:family], @params[:fontsize], :normal, :normal)
  @global[:descender] = xg_metrics.ink_height - xg_metrics.ink_above
  @global[:vmargin] = @params[:vmargin]
  @global[:box_vertical_margin] = if @params[:vmargin]
                                    single_x_metrics.height * @params[:vmargin] * 2
                                  else
                                    single_x_metrics.height * 0.8
                                  end
  # Every stroke follows the type size: linewidth 1 is 5% of it (the
  # ratio of an ordinary text rule, booktabs' \lightrulewidth), each
  # 0.5 step of the option adds another 2.5%, and a bold stroke adds
  # five percentage points. The old formula added absolute units
  # (linewidth + 1), so "1" actually meant 2 and small type got
  # disproportionately heavy lines.
  # Rounded, because these are written into the SVG as text and the
  # file is something people open and edit. Binary floating point turns
  # a line width of 1.5 into "2.4000000000000004" otherwise.
  @global[:stroke_normal] = (@params[:fontsize] * 0.05 * @params[:linewidth]).round(3)
  @global[:stroke_bold] = (@params[:fontsize] * (0.05 * @params[:linewidth] + 0.05)).round(3)
  # hyphen: literal swaps the two readings of - when a label is parsed.
  @global[:literal_hyphen] = @params[:hyphen] == "literal"
end

Class Method Details

.blank?(text) ⇒ Boolean

Whether the input is nothing to draw. Whitespace alone counts: it leaves no label behind, and reporting it as a defect in the library told the caller their own fixable input was our fault. Asked without strip when the bytes are not valid UTF-8, because strip raises there and that input is not blank — it is malformed, which the caller learns further down.

Returns:

  • (Boolean)


488
489
490
491
492
493
# File 'lib/rsyntaxtree.rb', line 488

def self.blank?(text)
  s = text.to_s
  return true if s.empty?

  s.valid_encoding? && s.strip.empty?
end

.check_data(text, params = {}) ⇒ Object

Raises:



495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
# File 'lib/rsyntaxtree.rb', line 495

def self.check_data(text, params = {})
  raise RSTError.new(+"Error: input text is empty", code: :empty_input, retryable: false) if blank?(text)

  begin
    StringParser.valid?(text)
    new(params.merge(data: text)).validate!
  rescue RSTError
    raise
  rescue StandardError => e
    # Callers of this are told they get a verdict, and machine callers
    # are told they get one in JSON. A defect in the drawing code is
    # still a verdict of "no", so it goes back in the same shape rather
    # than as a Ruby backtrace — named so it is not mistaken for a
    # mistake in the input.
    raise RSTError.new(+"Error: input could not be processed (#{e.class})",
                       code: :internal_error, retryable: false)
  end
end

.diagnose(text, params = {}) ⇒ Object

The whole diagnosis at once, as the hash the CLI prints: every error of the first stage that finds any, not just the first error found. Stages are ordered so that nothing is reported whose appearance is an artifact of an earlier mistake — a missing bracket shifts every token after it, an unreadable hyphen option changes what counts as markup — and when a stage stops the walk, the note says that fixing what is listed may reveal more. check_data keeps its contract (raise the first error) for callers that want a verdict rather than a list.



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
# File 'lib/rsyntaxtree.rb', line 567

def self.diagnose(text, params = {})
  errors = []
  note = nil
  if blank?(text)
    errors << RSTError.new(+"Error: input text is empty", code: :empty_input, retryable: false)
  else
    begin
      errors = option_errors(params)
      if errors.any?
        note = NOTE_OPTIONS
      else
        StringParser.valid?(text)
        gen = new(params.merge(data: text))
        collected, truncated = gen.collect_input_errors
        if collected.any?
          errors = collected
          note = NOTE_LABELS
          note += NOTE_TRUNCATED if truncated
        else
          gen.validate!
        end
      end
    rescue RSTError => e
      errors << e
      note = NOTE_STRUCTURE if %i[empty_brackets unbalanced_brackets].include?(e.code)
    rescue StandardError => e
      # The same promise check_data makes: a defect anywhere in here —
      # the option probing included — is still a verdict of "no", in
      # the same shape, not a backtrace.
      errors << RSTError.new(+"Error: input could not be processed (#{e.class})",
                             code: :internal_error, retryable: false)
    end
  end

  return { "ok" => true } if errors.empty?

  result = { "ok" => false, "errors" => errors.map(&:error_entry) }
  result["note"] = note if note
  result["reference"] = RSTError::REFERENCE
  result
end

.option_errors(params) ⇒ Object

Every option error at once. The constructor is the only judge of an option, and its rules are not written out a second time here: it stops at its first complaint, so it is asked again with the option it complained about set aside, until it accepts what is left or names nothing to set aside. Each round removes one option, so the loop is as bounded as the option list.



520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
# File 'lib/rsyntaxtree.rb', line 520

def self.option_errors(params)
  remaining = params.reject { |k, _| k.to_sym == :data }
  errors = []
  loop do
    begin
      new(remaining.merge(data: "[A a]"))
      break
    rescue RSTError => e
      errors << e
      key = e.label
      break if key.nil? || remaining.keys.none? { |k| k.to_s == key }

      remaining = remaining.reject { |k, _| k.to_s == key }
    end
  end
  errors
end

Instance Method Details

#collect_input_errorsObject

Parse in collect mode: every label that will not parse, and every rule name that turns out to have no rule behind it, recorded instead of raised. The walk is the real one — the same tokens, the same judgments — so what this reports and what drawing rejects cannot drift apart. Returns the errors and whether the list was cut short.



543
544
545
546
547
548
# File 'lib/rsyntaxtree.rb', line 543

def collect_input_errors
  sp = StringParser.new(@params[:data].gsub('&', '&amp;'), @params[:fontset],
                        @params[:fontsize], @global, collect_errors: true)
  sp.parse
  [sp.collected_errors, sp.more_errors?]
end

#draw_jsonObject



701
702
703
704
705
706
# File 'lib/rsyntaxtree.rb', line 701

def draw_json
  sp = StringParser.new(@params[:data].gsub('&', '&amp;'), @params[:fontset], @params[:fontsize], @global)
  sp.parse
  graph = JSONGraph.new(sp.get_elementlist, @params, @global)
  graph.json_data
end

#draw_lsifObject

The name the JSON output wore from 1.11.0 to 2.0.0; removed in 3.0.



709
710
711
# File 'lib/rsyntaxtree.rb', line 709

def draw_lsif
  draw_json
end

#draw_pdf(binary = false) ⇒ Object



679
680
681
682
683
684
685
686
687
688
689
690
691
692
# File 'lib/rsyntaxtree.rb', line 679

def draw_pdf(binary = false)
  surface = nil
  context = nil
  b = nil
  b = StringIO.new
  rsvg, surface = pdf_surface_for(draw_svg, b)
  context = Cairo::Context.new(surface)
  context.render_rsvg_handle(rsvg)
  surface.finish
  binary ? b : b.string
ensure
  b&.close unless binary
  context&.destroy
end

#draw_png(binary = false) ⇒ Object



663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
# File 'lib/rsyntaxtree.rb', line 663

def draw_png(binary = false)
  surface = nil
  context = nil
  b = nil
  rsvg, surface = raster_surface_for(draw_svg)
  context = Cairo::Context.new(surface)
  context.render_rsvg_handle(rsvg)
  b = StringIO.new
  surface.write_to_png(b)
  binary ? b : b.string
ensure
  b&.close unless binary
  surface&.finish
  context&.destroy
end

#draw_svgObject



694
695
696
697
698
699
# File 'lib/rsyntaxtree.rb', line 694

def draw_svg
  sp = StringParser.new(@params[:data].gsub('&', '&amp;'), @params[:fontset], @params[:fontsize], @global)
  sp.parse
  graph = SVGGraph.new(sp.get_elementlist, @params, @global)
  graph.svg_data
end

#draw_tikz(standalone: false, font: nil) ⇒ Object



713
714
715
716
717
718
# File 'lib/rsyntaxtree.rb', line 713

def draw_tikz(standalone: false, font: nil)
  sp = StringParser.new(@params[:data].gsub('&', '&amp;'), @params[:fontset], @params[:fontsize], @global)
  sp.parse
  generator = TikZGenerator.new(sp.get_elementlist, @params)
  generator.generate(standalone: standalone, font: font)
end

#pdf_surface_for(svg, target) ⇒ Object

The same for PDF, which in practice refuses nothing: a page 400,000 points wide is made without complaint where a raster surface stops at 32,767. Kept in the same shape as the raster path so that validation asks the same question of both, and so a future Cairo that does refuse a page size is heard.



653
654
655
656
657
658
659
660
661
# File 'lib/rsyntaxtree.rb', line 653

def pdf_surface_for(svg, target)
  rsvg = RSVG::Handle.new_from_data(svg)
  dim = rsvg.dimensions
  surface = Cairo::PDFSurface.new(target, dim.width, dim.height)
  yield surface if block_given?
  [rsvg, surface]
rescue Cairo::InvalidSize
  raise RSTError.new(+"Error: the result syntree is too big", code: :result_too_big, retryable: false)
end

#raster_surface_for(svg) ⇒ Object

The surface a raster format needs. Making it is where a tree too big to draw is found out — Cairo says so, nothing here knows the limit — and it is cheap next to painting the tree onto it, which is why validation makes one and stops there.



638
639
640
641
642
643
644
645
646
# File 'lib/rsyntaxtree.rb', line 638

def raster_surface_for(svg)
  rsvg = RSVG::Handle.new_from_data(svg)
  dim = rsvg.dimensions
  surface = Cairo::ImageSurface.new(Cairo::FORMAT_ARGB32, dim.width, dim.height)
  yield surface if block_given?
  [rsvg, surface]
rescue Cairo::InvalidSize
  raise RSTError.new(+"Error: the result syntree is too big", code: :result_too_big, retryable: false)
end

#switched_on?(value) ⇒ Boolean

Returns:

  • (Boolean)


476
477
478
479
480
# File 'lib/rsyntaxtree.rb', line 476

def switched_on?(value)
  return false if value.nil? || value == false

  !OFF.include?(value.to_s.strip.downcase)
end

#validate!Object

Generate, and throw the result away. Parsing alone leaves out the checks that only happen once the tree is laid out — a movement path with one end, a line with three — so validation that stopped at the parser passed input the drawing then rejected, which is the failure this validation exists to prevent.

Which generation depends on the format asked for, because the formats can refuse different things: a tree can be too wide for a raster surface while remaining a perfectly good SVG. Everything up to the last point where a format is known to say no is done; painting the tree onto the surface, which is most of the cost and refuses nothing this knows of, is not. A surface inside Cairo's limits but large enough to exhaust memory would still fail at that painting, so this is where validation is a strong guess rather than a guarantee.



623
624
625
626
627
628
629
630
631
632
# File 'lib/rsyntaxtree.rb', line 623

def validate!
  case @params[:format]
  when "png" then raster_surface_for(draw_svg, &:finish)
  when "pdf" then pdf_surface_for(draw_svg, StringIO.new, &:finish)
  when "json" then draw_json
  when "tikz" then draw_tikz
  else draw_svg
  end
  true
end