Class: RSyntaxTree::RSGenerator
- Inherits:
-
Object
- Object
- RSyntaxTree::RSGenerator
- 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
-
.blank?(text) ⇒ Boolean
Whether the input is nothing to draw.
- .check_data(text, params = {}) ⇒ Object
-
.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.
-
.option_errors(params) ⇒ Object
Every option error at once.
Instance Method Summary collapse
-
#collect_input_errors ⇒ Object
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.
- #draw_json ⇒ Object
-
#draw_lsif ⇒ Object
The name the JSON output wore from 1.11.0 to 2.0.0; removed in 3.0.
- #draw_pdf(binary = false) ⇒ Object
- #draw_png(binary = false) ⇒ Object
- #draw_svg ⇒ Object
- #draw_tikz(standalone: false, font: nil) ⇒ Object
-
#initialize(params = {}) ⇒ RSGenerator
constructor
A new instance of RSGenerator.
-
#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.
-
#raster_surface_for(svg) ⇒ Object
The surface a raster format needs.
- #switched_on?(value) ⇒ Boolean
-
#validate! ⇒ Object
Generate, and throw the result away.
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.
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
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_errors ⇒ Object
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('&', '&'), @params[:fontset], @params[:fontsize], @global, collect_errors: true) sp.parse [sp.collected_errors, sp.more_errors?] end |
#draw_json ⇒ Object
701 702 703 704 705 706 |
# File 'lib/rsyntaxtree.rb', line 701 def draw_json sp = StringParser.new(@params[:data].gsub('&', '&'), @params[:fontset], @params[:fontsize], @global) sp.parse graph = JSONGraph.new(sp.get_elementlist, @params, @global) graph.json_data end |
#draw_lsif ⇒ Object
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_svg ⇒ Object
694 695 696 697 698 699 |
# File 'lib/rsyntaxtree.rb', line 694 def draw_svg sp = StringParser.new(@params[:data].gsub('&', '&'), @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('&', '&'), @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
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 |