Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/src/Submakefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
184 changes: 174 additions & 10 deletions docs/src/extensions/image_resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ def rewrite(node)
abs = resolve_target(target, base_dir, lang, lang_re)
return unless abs
node.set_attr('target', abs)
apply_default_width(node)
apply_height_as_width(node, abs)
apply_default_width(node, abs)
apply_default_alignment(node)
return
end
Expand Down Expand Up @@ -321,19 +322,136 @@ 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')
return if node.attr('width')
node.set_attr('pdfwidth', '75%')
# 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')
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
# 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[/<svg\b[^>]*>/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
Expand All @@ -353,8 +471,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
Loading