From b4c629e7a2111f0c6bdca932e4a5b454b165d99e Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:49:38 +0800 Subject: [PATCH 1/3] docs: honor image height attribute in HTML and PDF The bundled asciidoctor stylesheet's img{height:auto} overrides the HTML height attribute, and asciidoctor-pdf ignores height entirely for raster images, so image::foo[height=400] was dropped in both backends. HTML: a postprocessor mirrors the width/height attributes into an inline style, which wins the cascade. PDF: convert a lone height into the equivalent pdfwidth using the file's intrinsic aspect ratio (PNG/JPEG/SVG size read without an image library). Closes #4201 --- docs/src/extensions/image_resolver.rb | 120 ++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/docs/src/extensions/image_resolver.rb b/docs/src/extensions/image_resolver.rb index b8f286ebf89..3a976c9fca2 100644 --- a/docs/src/extensions/image_resolver.rb +++ b/docs/src/extensions/image_resolver.rb @@ -113,6 +113,7 @@ def rewrite(node) abs = resolve_target(target, base_dir, lang, lang_re) return unless abs node.set_attr('target', abs) + apply_height_as_width(node, abs) apply_default_width(node) apply_default_alignment(node) return @@ -333,9 +334,82 @@ def apply_default_width(node) return if node.attr('pdfwidth') return if node.attr('scaledwidth') return if node.attr('width') + # A pinned height is an explicit size (already turned into pdfwidth by + # apply_height_as_width where readable); don't override it with 75%. + return if node.attr('height') node.set_attr('pdfwidth', '75%') end + # asciidoctor-pdf sizes images only by the width family and ignores the + # height attribute, so when only a height is given, convert it to the + # equivalent pdfwidth via the file's intrinsic aspect ratio. + def apply_height_as_width(node, abs) + return if node.context == :inline_image + return if node.attr('pdfwidth') || node.attr('scaledwidth') || + node.attr('width') || node.attr('scale') + h = node.attr('height') + return unless h && h.to_s.match?(/\A\d+(\.\d+)?\z/) + dims = intrinsic_size(abs) + return unless dims && dims[1] > 0 + px_w = h.to_f * dims[0] / dims[1] + node.set_attr('pdfwidth', format('%gpx', px_w)) + end + + # Intrinsic [width, height] in px for the formats we ship, without an image + # library. nil when the size can't be determined. + def intrinsic_size(path) + return nil unless path && File.file?(path) + case File.extname(path).downcase + when '.png' then png_size(path) + when '.jpg', '.jpeg' then jpeg_size(path) + when '.svg' then svg_size(path) + end + rescue StandardError + nil + end + + def png_size(path) + data = File.binread(path, 24) + return nil unless data && data.byteslice(0, 8) == "\x89PNG\r\n\x1A\n".b + w, h = data.byteslice(16, 8).unpack('N2') + (w && h) ? [w, h] : nil + end + + def jpeg_size(path) + File.open(path, 'rb') do |f| + return nil unless f.read(2) == "\xFF\xD8".b + while (marker = f.read(2)) + break unless marker.getbyte(0) == 0xFF + code = marker.getbyte(1) + len = f.read(2)&.unpack1('n') + break unless len + # SOF0..SOF15 carry the frame size (skip the non-frame markers). + if code >= 0xC0 && code <= 0xCF && ![0xC4, 0xC8, 0xCC].include?(code) + seg = f.read(5) + h, w = seg.byteslice(1, 4).unpack('n2') + return [w, h] + end + f.seek(len - 2, IO::SEEK_CUR) + end + end + nil + end + + def svg_size(path) + head = File.read(path, 2048) + return nil unless head + tag = head[/]*>/m] + return nil unless tag + w = tag[/\bwidth="([\d.]+)/, 1] + h = tag[/\bheight="([\d.]+)/, 1] + return [w.to_f, h.to_f] if w && h + if (vb = tag[/\bviewBox="([^"]+)"/, 1]) + nums = vb.split(/[\s,]+/).map(&:to_f) + return [nums[2], nums[3]] if nums.length == 4 && nums[3] > 0 + end + nil + end + # center images by default if no alignmen is given def apply_default_alignment(node) return if node.context == :inline_image @@ -353,8 +427,54 @@ def resolve_extension(path) nil end end + + # Asciidoctor emits image width/height as HTML attributes, but the bundled + # stylesheet's `img{height:auto}` (an author rule) outranks them in the + # cascade, so the requested height is dropped. CSS alone can't recover it, so + # mirror the width/height attributes into an inline style, which wins. Bare + # numbers become px; values with a unit or % pass through. + class ImageDimensionStyler < Asciidoctor::Extensions::Postprocessor + IMG_TAG_RE = /<(img|object)\b[^>]*>/i + DIM_RE = ->(name) { /\b#{name}="([^"]*)"/i } + + def process(document, output) + # Only the HTML backend hands the postprocessor a String of markup; the + # PDF converter passes its document object, so leave non-String output be. + return output unless output.is_a?(::String) + return output unless document.backend == 'html5' || document.basebackend?('html') + output.gsub(IMG_TAG_RE) { |tag| restyle(tag) } + end + + def restyle(tag) + decls = [] + %w[width height].each do |dim| + m = DIM_RE.call(dim).match(tag) + next unless m + v = m[1].strip + next if v.empty? + decls << "#{dim}:#{css_length(v)}" + end + return tag if decls.empty? + + style = decls.join(';') + if (sm = /\bstyle="([^"]*)"/i.match(tag)) + existing = sm[1].sub(/;\s*\z/, '') + merged = existing.empty? ? style : "#{existing};#{style}" + tag.sub(/\bstyle="[^"]*"/i, %(style="#{merged}")) + else + tag.sub(/<(img|object)\b/i, %(<\\1 style="#{style}")) + end + end + + # A bare integer/decimal is a pixel count (HTML attribute semantics); + # anything that already names a unit or percentage is left untouched. + def css_length(v) + v.match?(/\A\d+(\.\d+)?\z/) ? "#{v}px" : v + end + end end Asciidoctor::Extensions.register do treeprocessor LinuxCNCDocs::ImageResolver + postprocessor LinuxCNCDocs::ImageDimensionStyler end From f32fb71f40271d6ee05929663f9db8c7cf2a76f4 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:07:50 +0800 Subject: [PATCH 2/3] docs: size PDF images by resolution instead of a blanket 75% apply_default_width forced every width/height-less image to 75% of the text column, stretching small images far beyond their intent: the 96-DPI equation captures rendered ~4x too large, and screenshots like the pyvcp dial (210px) rendered at 40 DPI / 5.2in. Size each image at its intended resolution instead: the embedded PNG pHYs when present, else an assumed 96 DPI screen capture. Images wider than the 75% cap, or whose size can't be read, keep the 75% fallback. --- docs/src/extensions/image_resolver.rb | 64 ++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/docs/src/extensions/image_resolver.rb b/docs/src/extensions/image_resolver.rb index 3a976c9fca2..6d4a4502dcb 100644 --- a/docs/src/extensions/image_resolver.rb +++ b/docs/src/extensions/image_resolver.rb @@ -114,7 +114,7 @@ def rewrite(node) return unless abs node.set_attr('target', abs) apply_height_as_width(node, abs) - apply_default_width(node) + apply_default_width(node, abs) apply_default_alignment(node) return end @@ -322,14 +322,21 @@ def rewrite_inline(text, base_dir, lang, lang_re, pdf, document) end end - # asciidoctor-pdf renders raster images at native pixel dimensions - # interpreted as 72 DPI, then caps at content width. Most of our - # source PNGs are screenshots/diagrams sized for ~150 DPI display, so - # the default behaviour blows them up to full text column width and - # leaves big half-blank pages where they break across a page boundary. - # dblatex defaulted to a smaller fit. Approximate that by setting a - # default pdfwidth when the source did not pin width/scaledwidth/pdfwidth. - def apply_default_width(node) + # A4 (595.28pt) minus the theme's 0.67in side margins leaves a 498.8pt text + # column; the historical cap for an over-wide image is 75% of it. + TEXT_COLUMN_PT = 498.8 + DEFAULT_IMAGE_WIDTH = 0.75 + # Screenshots/diagrams are captured at screen resolution; assume 96 DPI when + # a raster image carries no embedded resolution of its own. + DEFAULT_SCREEN_DPI = 96.0 + + # asciidoctor-pdf renders raster images at native pixels interpreted as + # 72 DPI, then caps at content width, so an unsized image blows up to the + # full text column. Instead, size each image at its intended resolution -- + # the embedded pHYs when present (e.g. the 96-DPI equation captures), else + # an assumed 96 DPI screen capture. Only images wider than the 75% cap, or + # whose size we cannot read, fall back to the historical 75%-of-column. + def apply_default_width(node, abs = nil) return if node.context == :inline_image return if node.attr('pdfwidth') return if node.attr('scaledwidth') @@ -337,7 +344,44 @@ def apply_default_width(node) # A pinned height is an explicit size (already turned into pdfwidth by # apply_height_as_width where readable); don't override it with 75%. return if node.attr('height') - node.set_attr('pdfwidth', '75%') + native_pt = abs && native_width_pt(abs) + if native_pt && native_pt <= TEXT_COLUMN_PT * DEFAULT_IMAGE_WIDTH + node.set_attr('pdfwidth', format('%gpt', native_pt.round(2))) + else + node.set_attr('pdfwidth', '75%') + end + end + + # Intended width in pt for a raster image: its embedded pHYs resolution when + # present, otherwise an assumed 96 DPI. nil for formats we don't size here. + def native_width_pt(path) + ext = File.extname(path).downcase + return nil unless ['.png', '.jpg', '.jpeg'].include?(ext) + dims = intrinsic_size(path) + return nil unless dims && dims[0] > 0 + dpi = (ext == '.png' && png_dpi(path)) || DEFAULT_SCREEN_DPI + dims[0] * 72.0 / dpi + end + + # Pixels-per-inch from the PNG pHYs chunk (unit 1 => pixels per metre), or + # nil when absent. pHYs always precedes IDAT, so stop at the pixel data. + def png_dpi(path) + data = File.binread(path, 4096) + return nil unless data && data.byteslice(0, 8) == "\x89PNG\r\n\x1A\n".b + off = 8 + while off + 17 <= data.bytesize + len = data.byteslice(off, 4).unpack1('N') + type = data.byteslice(off + 4, 4) + break if type == 'IDAT' + if type == 'pHYs' + ppu_x, _ppu_y, unit = data.byteslice(off + 8, 9).unpack('N2C') + return (unit == 1 && ppu_x && ppu_x > 0) ? ppu_x * 0.0254 : nil + end + off += 12 + len + end + nil + rescue StandardError + nil end # asciidoctor-pdf sizes images only by the width family and ignores the From cadf0302fe8693caee540757553bd967a965f9e9 Mon Sep 17 00:00:00 2001 From: Luca Toniolo <10792599+grandixximo@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:07:50 +0800 Subject: [PATCH 3/3] docs: rebuild HTML/PDF when an asciidoctor extension changes The HTML and PDF rules did not depend on docs/src/extensions/*.rb, so editing a resolver left previously built docs stale. Add DOC_EXTENSIONS as a prerequisite of both rules. --- docs/src/Submakefile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/src/Submakefile b/docs/src/Submakefile index 2bdafc3d491..fe0fc964828 100644 --- a/docs/src/Submakefile +++ b/docs/src/Submakefile @@ -28,6 +28,9 @@ ECHO ?= /usr/bin/echo SRCDIR=../src DOC_DIR=../docs DOC_SRCDIR=../docs/src +# Asciidoctor extensions loaded via -r for every doc render; a prerequisite of +# the HTML and PDF rules so editing one rebuilds the affected docs. +DOC_EXTENSIONS=$(wildcard $(DOC_SRCDIR)/extensions/*.rb) # Unified output tree. All asciidoctor-generated artefacts (HTML, PDF, # po4a-translated .adoc) live under DOC_BUILD so the source tree stays @@ -955,7 +958,7 @@ $(DOC_FONT_DIR): # to rebuild on every run. Order-only (after the |) instead; the SVGs # the PDF embeds are tracked via .adoc-images-stamp. define ASCIIDOCTOR_PDF_RULE -$(4)/%.pdf: $(1)/%.adoc .adoc-images-stamp $$(DOC_FONTS) | svgs_made_from_dots +$(4)/%.pdf: $(1)/%.adoc .adoc-images-stamp $$(DOC_FONTS) $$(DOC_EXTENSIONS) | svgs_made_from_dots $$(ECHO) Building $$@ @mkdir -p $$(dir $$@) @rm -f $$@ $$@.raw @@ -1214,7 +1217,7 @@ $(DOC_OUT_ADOC)/en/%.html: LCNC_CSSREL=$(shell python3 -c "print('../' * (1 + '$ define ASCIIDOCTOR_HTML_RULE # Order-only dep on .adoc-images-stamp so translated images are staged before # the resolver probes for them at render (it also falls back to docs/src). -$$(patsubst %.adoc,$2/%.html,$$(DOC_SRCS_$(call toUC,$1)_SMALL)): $2/%.html: $2/%.adoc $$(DOC_SRCDIR)/docinfo.html $$(DOC_SRCDIR)/docinfo-header.html | .adoc-images-stamp +$$(patsubst %.adoc,$2/%.html,$$(DOC_SRCS_$(call toUC,$1)_SMALL)): $2/%.html: $2/%.adoc $$(DOC_SRCDIR)/docinfo.html $$(DOC_SRCDIR)/docinfo-header.html $$(DOC_EXTENSIONS) | .adoc-images-stamp $$(ECHO) "Building '$1' adoc to html: " $$< $$(Q)asciidoctor -r $$(realpath $$(DOC_SRCDIR))/extensions/xref_resolver.rb \ -r $$(realpath $$(DOC_SRCDIR))/extensions/image_resolver.rb \