diff --git a/packages/dropcaps/init.lua b/packages/dropcaps/init.lua index 764de53df..ac7b6bccd 100644 --- a/packages/dropcaps/init.lua +++ b/packages/dropcaps/init.lua @@ -9,16 +9,58 @@ function package:_init () self:loadPackage("raiselower") end -local shapeHbox = function (options, content) - -- Clear irrelevant values before passing to font - options.lines, options.join, options.raise, options.shift, options.color, options.scale = nil, nil, nil, nil, nil, nil - SILE.call("noindent") +function package.declareSettings (_) + SILE.settings:declare({ + parameter = "dropcaps.bsratio", + type = "number or nil", + default = nil, -- nil means "use computed value based on font metrics" + help = "When set, fixed default ratio of the descender with respect to the baseline (around 0.3 in usual fonts)." + }) +end + +local function shapeHbox (options, content) local hbox = SILE.typesetter:makeHbox(function () SILE.call("font", options, content) end) return hbox end +local metrics = require("fontmetrics") +local bsratiocache = {} + +local computeBaselineRatio = function () + local fontoptions = SILE.font.loadDefaults({}) + local bsratio = bsratiocache[SILE.font._key(fontoptions)] + if not bsratio then + local face = SILE.font.cache(fontoptions, SILE.shaper.getFace) + local m = metrics.get_typographic_extents(face) + bsratio = m.descender / (m.ascender + m.descender) + bsratiocache[SILE.font._key(fontoptions)] = bsratio + end + return bsratio +end + +local function getToleranceDepth () + -- In non-strict mode, we allow using more lines to fit the dropcap. + -- However we cannot just check if the "extra depth" of the dropcap is above 0. + -- First, our depth adjustment is but a best attempt. + -- Moreover, some characters may have a small depth of their own (ex. "O" in Gentium Plus) + -- We must just ensure they stay within "reasonable bounds" with respect to the baseline, + -- so as not to flow over the next lines. + -- We compute a tolerance ratio based on the font metrics, expecting the font to be well-designed. + -- The user can override the computation and set the dropcaps.bsratio setting manually. + -- (LaTeX would likely approximate it using a \strut = with a depth ratio of 0.3bs) + local bsratio + if SILE.settings:get("dropcaps.bsratio") then + bsratio = SILE.settings:get("dropcaps.bsratio") + SU.debug("dropcaps", "Using user-defined descender baseline ratio", bsratio) + else + bsratio = computeBaselineRatio() + SU.debug("dropcaps", "Using computed descender baseline ratio", bsratio) + end + return bsratio * SILE.measurement("1bs"):tonumber() +end + function package:registerCommands () -- This implementation relies on the hangafter and hangindent features of Knuth's line-breaking algorithm. @@ -31,41 +73,83 @@ function package:registerCommands () local shift = SU.cast("measurement", options.shift or 0) local size = SU.cast("measurement or nil", options.size or nil) local scale = SU.cast("number", options.scale or 1.0) + local strict = SU.boolean(options.strict, true) + if strict and options.depthadjust then + SU.warn("The depthadjust option is ignored in strict mode.") + end local color = options.color - options.size = nil -- we need to measure the "would have been" size before using this + -- We need to measure the "would have been" size before using this. + options.size = nil + -- Clear irrelevant option values before passing to font and measuring content. + options.lines, options.join, options.raise, options.shift, options.color, options.scale = nil, nil, nil, nil, nil, nil if color then self:loadPackage("color") end + -- Some initial capital fonts have all their glyphs hanging below the baseline (e.g. EB Garamond Initials) + -- We cannot manage all pathological cases. + -- Quite empirically, we can shape character(s) which shouldn't usually have a depth normally. + -- If it has, then likely all glyphs do also and we need to compensate for that everywhere. + local depthadjust = options.depthadjust or "I" + local depthAdjustment = not strict and shapeHbox(options, { depthadjust }).depth:tonumber() or 0 + SU.debug("dropcaps", "Depth adjustment", depthAdjustment) + -- We want the drop cap to span over N lines, that is N - 1 baselineskip + the height of the first line. -- Note this only works for the default linespace mechanism. -- We determine the height of the first line by measuring the size the initial content *would have* been. - -- This gives the font some control over its relative size, sometimes desired sometimes undesired. local tmpHbox = shapeHbox(options, content) local extraHeight = SILE.measurement((lines - 1).."bs"):tonumber() - local targetHeight = tmpHbox.height:tonumber() * scale + extraHeight + local curHeight = tmpHbox.height:tonumber() + depthAdjustment + local targetHeight = (curHeight - depthAdjustment) * scale + extraHeight + if strict then + -- Take into account the compensated depth of the initial + curHeight = curHeight + tmpHbox.depth:tonumber() + end SU.debug("dropcaps", "Target height", targetHeight) -- Now we need to figure out how to scale the dropcap font to get an initial of targetHeight. -- From that we can also figure out the width it will be at that height. local curSize = SILE.measurement(SILE.settings:get("font.size")):tonumber() - local curHeight, curWidth = tmpHbox.height:tonumber(), tmpHbox.width:tonumber() + local curWidth = tmpHbox.width:tonumber() options.size = size and size:tonumber() or (targetHeight / curHeight * curSize) local targetWidth = curWidth / curSize * options.size SU.debug("dropcaps", "Target font size", options.size) SU.debug("dropcaps", "Target width", targetWidth) - -- Typeset the dropcap with its final shape, but don't output it yet + -- Typeset the dropcap with its final shape, but don't output it yet. local hbox = shapeHbox(options, content) + if not strict then + -- Compensation for regular extra depth. + local compensationHeight = depthAdjustment * options.size / curSize + SU.debug("dropcaps", "Compensation height", compensationHeight) + + -- Some fonts have descenders on letters such as Q, J, etc. + -- In that case we may need extra lines to the dropcap. + local extraDepth = hbox.depth:tonumber() - compensationHeight + local toleranceDepth = getToleranceDepth() + if extraDepth > toleranceDepth then + SU.debug("dropcaps", "Extra depth", extraDepth, "> tolerance", toleranceDepth) + local extraLines = math.ceil((extraDepth - toleranceDepth) / SILE.measurement("1bs"):tonumber()) + lines = lines + extraLines + SU.debug("dropcaps", "Extra lines needed to fit", extraLines) + else + SU.debug("dropcaps", "Extra depth", extraDepth, "< tolerance", toleranceDepth) + end + raise = raise:tonumber() + compensationHeight + else + raise = raise:tonumber() + hbox.depth:tonumber() + end + -- Setup up the necessary indents for the final paragraph content local joinOffset = join and standoff:tonumber() or 0 SILE.settings:set("current.hangAfter", -lines) SILE.settings:set("current.hangIndent", targetWidth + joinOffset) + SILE.call("noindent") SU.debug("dropcaps", "joinOffset", joinOffset) -- The paragraph is indented so as to leave enough space for the drop cap. -- We "trick" the typesetter with a zero-dimension box wrapping our original box. - SILE.call("rebox", { height = 0, width = -joinOffset }, function () + SILE.call("rebox", { height = 0, depth = 0, width = -joinOffset }, function () SILE.call("glue", { width = shift - targetWidth - joinOffset }) SILE.call("lower", { height = extraHeight - raise }, function () SILE.call(color and "color" or "noop", { color = color }, function () @@ -79,6 +163,7 @@ end package.documentation = [[ \begin{document} +\use[module=packages.dropcaps] The \autodoc:package{dropcaps} package allows you to format paragraphs with an “initial capital” (also commonly referred as a “drop cap”), typically one large capital letter used as a decorative element at the beginning of a paragraph. It provides the \autodoc:command{\dropcap} command. @@ -92,6 +177,18 @@ To tweak the position of the dropcap, measurements may be passed to the \autodoc Other options passed to \autodoc:command{\dropcap} will be passed through to \autodoc:command{\font} when drawing the initial letter(s). This may be useful for passing OpenType options or other font preferences. +Some fonts have capitals — such as, typically, \autodoc:example{Q} and \autodoc:example{J} — hanging below the baseline. +By default, the dropcap fits the specified number of lines and the characters are typeset in a smaller size to fit these descenders. + +With the \autodoc:parameter{strict=false} option, the characters are scaled with respect to their height only, and extra hanged lines are added to the dropcap in order to accommodate the descenders. +The dropcap is allowed to overflow the baseline by a reasonable amount, before triggering the addition of extra lines, for fonts that have capitals very slightly hanging below the baseline. +This tolerance is computed based on the font metrics. +If you want to bypass this mechanism and adjust the tolerance, you can use the \autodoc:setting{dropcaps.bsratio} setting. + +Moreover, some fonts, such as EB Garamond Initials, have \em{all} capitals hanging below the baseline. +To take this case into account in non-strict mode, the depth adjustment of the dropcap is empirically corrected based on that of a character which shouldn't have any, by default an \autodoc:example{I}. +The character(s) used for this depth adjustment correction can be specified using the \autodoc:parameter{depthadjust} option. + \begin{autodoc:note} One caveat is that the size of the initials is calculated using the default linespacing mechanism. If you are using an alternative method from the \autodoc:package{linespacing} package, you might see strange results.