Embed SVG icons directly into output.
authorNick Bowler <nbowler@draconx.ca>
Tue, 23 Feb 2021 01:29:38 +0000 (20:29 -0500)
committerNick Bowler <nbowler@draconx.ca>
Wed, 24 Feb 2021 03:48:54 +0000 (22:48 -0500)
Inlining SVGs directly in the markup seems to interoperate somewhat
better than linking SVGs in img elements, and we can fairly easily
create fallback markup that degrades to a raster image.

Currently this is only applicable to the file listings because the
fallback markup is hardcoded to the icons on these pages.  This will
need to be expanded in the future.

The results seem pretty good, although nothing I do in old Mozilla
versions seems to be able to get the stylesheet sizes to properly
apply to SVG elements, so the icons are rendered correctly but at
the wrong scale.  SVG support can be disabled in this browser and
the fallback works properly.  More work may be required.

14 files changed:
Rules
content/icons/down.svg [moved from content/images/down.svg with 100% similarity]
content/icons/folder.svg [moved from content/images/folder.svg with 100% similarity]
content/icons/return.svg [moved from content/images/return.svg with 100% similarity]
content/icons/up.svg [moved from content/images/up.svg with 100% similarity]
content/style.scss
layouts/clickytable.xsl
layouts/default.xsl
layouts/embed-svg.xsl [new file with mode: 0644]
layouts/functions.xsl
layouts/listing.erb
lib/css-clean-selectors.rb
lib/svg2png.rb [new file with mode: 0644]
lib/xhtml-compat.rb

diff --git a/Rules b/Rules
index ebe9cf2f23a67545e868e87e0f54a04567f7e634..d3247cb8e2be32b62a75789495b9ed79ae29c05e 100644 (file)
--- a/Rules
+++ b/Rules
@@ -132,8 +132,9 @@ compile '/**/index.lst' do
     layout '/listing.erb'
     layout '/default.xml'
     layout '/default.xsl'
+    layout '/embed-svg.xsl'
     filter :relativize_paths, type: :xml
-    filter :xhtml_compat
+    filter :xhtml_compat, fix_doctype: true
     write item.identifier.without_ext + ".xhtml"
 end
 
@@ -209,6 +210,11 @@ compile '/**/*.svg' do
     write @item.identifier.to_s
 end
 
+compile '/icons/**/*.svg', rep: :icon32 do
+    filter :svg2png, width: 32, height: 32
+    write @item.identifier.without_ext + "-32.png"
+end
+
 compile '/**/*' do
     filter :copybin if @item.binary?
     write @item.identifier.to_s
similarity index 100%
rename from content/images/up.svg
rename to content/icons/up.svg
index 524077925c5f788c62a9b0972764b65cfc2c3b64..912fe4aa7796124d590af0929587a1dd08b8c594 100644 (file)
@@ -240,7 +240,7 @@ $clickynames: name, date, size;
             }
         }
 
-        &:focus ~ table th.clicky-#{$col}>label~label>span {
+        &:focus ~ table th.clicky-#{$col}>label~label>span:first-child {
             border-color: $foregroundcolour;
         }
 
@@ -256,15 +256,15 @@ $clickynames: name, date, size;
         &:checked ~ table {
             // Update table header state
             & th.clicky-#{$col} {
-                img+img {
+                .svg+.svg {
                     display: -moz-inline-box !important;
                     display: inline-block !important;
                 }
-                img { display: none; }
+                .svg { display: none; }
             }
         }
 
-        &:focus ~ table th.clicky-#{$col}>label~label>img {
+        &:focus ~ table th.clicky-#{$col}>label~label .svg {
             border-color: $foregroundcolour;
         }
 
@@ -275,16 +275,17 @@ $clickynames: name, date, size;
     }
 
     th.clicky-#{$col}>label {
-        &, &>* {
-            white-space: nowrap;
-            vertical-align: middle;
+        white-space: nowrap;
+        cursor: pointer;
+        line-height: 1.5em;
+
+        &>* {
             display: -moz-inline-box;
             display: inline-block;
-            cursor: pointer;
+            border: 1px dotted transparent;
+            vertical-align: middle;
         }
 
-        &>* { border: 1px dotted transparent; }
-
         // Expand the first label a bit so the table (hopefully)
         // does not reshape as columns are selected.
         &:first-child {
@@ -293,28 +294,39 @@ $clickynames: name, date, size;
         }
 
         &:active { color: $linkactivecolour; }
-        &:first-child:active>span, &~label:active>img {
+        &:first-child:active>span, &~label:active>.svg {
             border-color: $linkactivecolour;
         }
 
-        img {
+        .svg {
             margin-left: 0.25em;
-            width: 1.5em;
-            height: auto;
         }
+
+        .svg, svg, img.svgfallback {
+            height: 1.5em;
+            width: auto;
+        }
+        .svg svg { width: 1.5em; }
     }
 }
 
 table.filelist {
-    &>tr>*:first-child, &>*>tr>*:first-child {
-        &+td { min-width: 50%; }
-        width: 0;
+    &>*>tr>*:first-child {
+        &+* { width: 50%; }
+        // chrome doesn't like width: 0 for some reason
+        width: 0.1px;
     }
 
-    tbody img {
-        display: block;
-        height: 1.5em;
-        width: auto;
+    tbody {
+        .svg, svg, img.svgfallback {
+            height: 1.5em;
+            width: auto;
+        }
+
+        .svg {
+            svg { width: 1.5em; }
+            display: inline-block;
+        }
     }
 }
 
index f442fa46c387379c9a4db225e6c02743307add08..4858b7fbbfebd0eb3412d9a9fcad68f6d3892771 100644 (file)
@@ -86,8 +86,8 @@
     <label for='rev-{generate-id(@clicky)}' style='display: none'>
       <xsl:text>&#x2060;</xsl:text>
       <span><xsl:apply-templates select='node()' /></span>
-      <img alt='FWD' width='16' height='16' src='/images/down.svg' />
-      <img alt='REV' width='16' height='16' src='/images/up.svg' style='display: none' />
+      <img alt='FWD' width='16' height='16' src='/icons/down.svg' />
+      <img alt='REV' width='16' height='16' src='/icons/up.svg' style='display: none' />
     </label>
     <script type='x'>--></script>
   </xsl:copy>
index 22f67d6351e713d8dbf2b1774e783cbe167e82c8..f0fddc97bb27cf9063ad403f27612f4c35b48953 100644 (file)
   exclude-result-prefixes='xhtml'>
 
 <xsl:import href='layouts/functions.xsl' />
-
-<xsl:output method='xml' encoding='UTF-8' indent='yes'
-  doctype-public='-//W3C//DTD XHTML 1.1//EN'
-  doctype-system='http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd'
-  cdata-section-elements='style script' />
+<xsl:output cdata-section-elements='style script' />
 
 <xsl:param name='source-uri'
   select='"//git.draconx.ca/gitweb/homepage.git/"' />
diff --git a/layouts/embed-svg.xsl b/layouts/embed-svg.xsl
new file mode 100644 (file)
index 0000000..2b3f8e2
--- /dev/null
@@ -0,0 +1,251 @@
+<?xml version='1.0' encoding='UTF-8' ?>
+<!--
+  Nick's web site: SVG embedding.
+
+  Copyright © 2021 Nick Bowler
+
+  This program is free software: you can redistribute it and/or modify
+  it under the terms of the GNU General Public License as published by
+  the Free Software Foundation, either version 3 of the License, or
+  (at your option) any later version.
+
+  This program is distributed in the hope that it will be useful,
+  but WITHOUT ANY WARRANTY; without even the implied warranty of
+  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  GNU General Public License for more details.
+
+  You should have received a copy of the GNU General Public License
+  along with this program.  If not, see <https://www.gnu.org/licenses/>
+-->
+<xsl:stylesheet version='1.0'
+  xmlns='http://www.w3.org/1999/xhtml'
+  xmlns:cc='http://creativecommons.org/ns#'
+  xmlns:dc='http://purl.org/dc/elements/1.1/'
+  xmlns:f='http://draconx.ca/my-functions'
+  xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'
+  xmlns:svg="http://www.w3.org/2000/svg"
+  xmlns:xhtml='http://www.w3.org/1999/xhtml'
+  xmlns:xlink='http://www.w3.org/1999/xlink'
+  xmlns:xsl='http://www.w3.org/1999/XSL/Transform'
+  extension-element-prefixes="f"
+  exclude-result-prefixes="cc dc rdf xhtml">
+
+<xsl:import href='layouts/functions.xsl' />
+<xsl:output cdata-section-elements='style script' />
+<xsl:strip-space elements='xhtml:html xhtml:body' />
+
+<xsl:key name='embed-svg' match='//xhtml:img[f:ends-with(@src, ".svg")]'
+  use='@src' />
+
+<xsl:key name='id' match='//*[@id]' use='@id' />
+
+<xsl:template match='node()|@*'>
+  <xsl:copy>
+    <xsl:apply-templates select='node()|@*' />
+  </xsl:copy>
+</xsl:template>
+
+<!-- force a literal result to ensure html gets all namespace nodes -->
+<xsl:template name='rewrite-html'>
+  <html>
+    <xsl:apply-templates select='node()|@*' />
+  </html>
+</xsl:template>
+
+<xsl:template match='/xhtml:html'>
+  <xsl:choose>
+    <xsl:when test='//xhtml:img[key("embed-svg", @src)]'>
+      <xsl:call-template name='rewrite-html' />
+    </xsl:when>
+    <xsl:otherwise>
+      <xsl:element name='{name()}' namespace='{namespace-uri()}'>
+        <xsl:apply-templates select='node()|@*' />
+      </xsl:element>
+    </xsl:otherwise>
+  </xsl:choose>
+</xsl:template>
+
+<xsl:template match='xhtml:img[key("embed-svg", @src)]'>
+  <span class='svg'>
+    <xsl:apply-templates select='@style' />
+    <svg:svg>
+      <xsl:apply-templates select='@width|@height' />
+      <svg:switch>
+        <svg:use xlink:href='#es-{generate-id(key("embed-svg", @src))}' />
+        <svg:foreignObject width='0' height='0'>
+          <!-- TODO: this hardcoded fallback method needs to be more flexible -->
+          <xsl:copy>
+            <xsl:attribute name='src'>
+              <xsl:value-of select='substring-before(@src, ".svg")' />
+              <xsl:text>-32.png</xsl:text>
+            </xsl:attribute>
+            <xsl:attribute name='class'>svgfallback</xsl:attribute>
+            <xsl:apply-templates select='node()|
+                  @*[not(contains("src style class", local-name()))]' />
+          </xsl:copy>
+        </svg:foreignObject>
+      </svg:switch>
+    </svg:svg>
+  </span>
+</xsl:template>
+
+<!-- SVG embedding -->
+<xsl:template mode='embed-svg' match='node()|@*'>
+  <xsl:copy>
+    <xsl:apply-templates select='node()|@*' />
+  </xsl:copy>
+</xsl:template>
+
+<!-- Remove all whitespace-only text nodes from embedded SVG -->
+<xsl:template mode='embed-svg' match='text()[normalize-space(.) = ""]' />
+
+<xsl:template name='svgnode' mode='embed-svg' match='svg:*'>
+  <xsl:param name='idnode' select='.' />
+
+  <!-- doctype demands svg: prefix -->
+  <xsl:element name='svg:{local-name()}' namespace='{namespace-uri()}'>
+    <!-- rewrite all id nodes to avoid conflicts with other embeddings -->
+    <xsl:if test='@id or not(parent::*)'>
+      <xsl:attribute name='id'>
+        <xsl:value-of select='concat("es-", generate-id($idnode))' />
+      </xsl:attribute>
+    </xsl:if>
+    <xsl:apply-templates mode='embed-svg' select='@*[local-name()!="id"]' />
+    <xsl:apply-templates mode='embed-svg' select='node()'>
+      <xsl:sort select='-count(self::svg:metadata)' data-type='number' />
+    </xsl:apply-templates>
+  </xsl:element>
+</xsl:template>
+
+<xsl:template name='depx'>
+  <xsl:param name='node' select='.' />
+  <xsl:choose>
+    <xsl:when test='contains($node, "px")'>
+      <xsl:value-of select='substring-before($node, "px")' />
+    </xsl:when>
+    <xsl:otherwise>
+      <xsl:value-of select='node' />
+    </xsl:otherwise>
+  </xsl:choose>
+</xsl:template>
+
+<xsl:template mode='embed-svg' match='/svg:svg/@width[../@height]'>
+  <xsl:attribute name='viewBox'>
+    <xsl:text>0 0 </xsl:text>
+    <xsl:call-template name='depx' />
+    <xsl:text> </xsl:text>
+    <xsl:call-template name='depx'>
+      <xsl:with-param name='node' select='../@height' />
+    </xsl:call-template>
+  </xsl:attribute>
+</xsl:template>
+<xsl:template mode='embed-svg' match='/svg:svg/@height[../@width]' />
+
+<!--
+  the RDF stuff is disallowed by doctype, try and transform to an
+  attribution comment.
+-->
+<xsl:template mode='embed-svg' match='svg:metadata'>
+  <xsl:if test='descendant::cc:Work'>
+    <xsl:comment>
+      <xsl:apply-templates select='descendant::cc:Work' />
+    </xsl:comment>
+  </xsl:if>
+</xsl:template>
+
+<!-- hackjob to stringify the work info -->
+<xsl:template match='cc:Work'>
+  <xsl:text>
+  </xsl:text>
+  <xsl:if test='dc:title'>
+    <xsl:value-of select='concat("“", dc:title, "”")' />
+  </xsl:if>
+  <xsl:if test='dc:creator/cc:Agent'>
+    <xsl:text> by </xsl:text>
+    <xsl:for-each select='dc:creator/cc:Agent'>
+      <xsl:value-of select='dc:title' />
+      <xsl:choose>
+        <xsl:when test='position()=last()'>
+          <xsl:text>.</xsl:text>
+        </xsl:when>
+        <xsl:otherwise>
+          <xsl:text>, </xsl:text>
+        </xsl:otherwise>
+      </xsl:choose>
+    </xsl:for-each>
+  </xsl:if>
+  <xsl:if test='dc:source'>
+    <xsl:text>
+  </xsl:text>
+    <xsl:value-of select='dc:source' />
+  </xsl:if>
+  <xsl:if test='cc:license/@rdf:resource'>
+    <xsl:text>
+  </xsl:text>
+    <xsl:value-of select='cc:license/@rdf:resource' />
+  </xsl:if>
+  <xsl:text>
+</xsl:text>
+</xsl:template>
+
+<!-- match all xlink:href attributes to generated IDs for this document -->
+<xsl:template mode='embed-svg' match='@xlink:href[starts-with(.,"#")]'>
+  <xsl:variable name='ref' select='substring-after(., "#")' />
+
+  <xsl:attribute name='xlink:href'>
+    <xsl:value-of select='concat("#es-", generate-id(key("id", $ref)))' />
+  </xsl:attribute>
+</xsl:template>
+
+<!-- rewrite all attributes containing url(#id) to generated IDs -->
+<xsl:template name='rewrite-urls'>
+  <xsl:param name='val' select='.' />
+
+  <xsl:choose>
+    <xsl:when test='contains($val, "url(#")'>
+      <xsl:variable name='tail' select='substring-after($val, "url(#")' />
+      <xsl:variable name='ref' select='substring-before($tail, ")")' />
+
+      <xsl:value-of select='substring-before($val, "url(#")' />
+      <xsl:text>url(</xsl:text>
+      <xsl:value-of select='concat("#es-", generate-id(key("id", $ref)))' />
+      <xsl:text>)</xsl:text>
+      <xsl:call-template name='rewrite-urls'>
+        <xsl:with-param name='val' select='substring-after($tail, ")")' />
+      </xsl:call-template>
+    </xsl:when>
+    <xsl:otherwise>
+      <xsl:value-of select='$val' />
+    </xsl:otherwise>
+  </xsl:choose>
+</xsl:template>
+
+<xsl:template mode='embed-svg' match='@*[contains(., "url(#")]'>
+  <xsl:attribute name='{name()}'>
+    <xsl:call-template name='rewrite-urls' />
+  </xsl:attribute>
+</xsl:template>
+
+<xsl:template match='xhtml:body'>
+  <xsl:copy>
+    <xsl:apply-templates select='node()|@*' />
+    <xsl:if test='key("embed-svg", //xhtml:img/@src)'>
+      <script type='x'><![CDATA[]]x><!--]]></script>
+      <svg:svg width='0' height='0'>
+        <svg:defs>
+          <xsl:for-each select='//xhtml:img'>
+            <xsl:if test='generate-id(.)=generate-id(key("embed-svg", @src))'>
+              <xsl:apply-templates mode='embed-svg'
+                select='document(concat("output", @src))'>
+                <xsl:with-param name='idnode' select='.' />
+              </xsl:apply-templates>
+            </xsl:if>
+          </xsl:for-each>
+        </svg:defs>
+      </svg:svg>
+      <script type='x'>--></script>
+    </xsl:if>
+  </xsl:copy>
+</xsl:template>
+
+</xsl:stylesheet>
index d9c5748053bbf4a3759b4a572d05f5d90061c12f..a88c3380cc88f3bb0d59491bcb6e33f8316f8830 100644 (file)
@@ -1,7 +1,7 @@
 <!--
   Nick's web site: XSLT helper functions.
 
-  Copyright © 2019-2020 Nick Bowler
+  Copyright © 2019-2021 Nick Bowler
 
   This program is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   along with this program.  If not, see <https://www.gnu.org/licenses/>
 -->
 <xsl:stylesheet version='1.0'
+  xmlns='http://www.w3.org/1999/xhtml'
   xmlns:xhtml='http://www.w3.org/1999/xhtml'
   xmlns:xsl='http://www.w3.org/1999/XSL/Transform'
   xmlns:func='http://exslt.org/functions'
   xmlns:f='http://draconx.ca/my-functions'
   extension-element-prefixes='func f'>
 
+<xsl:output method='xml' encoding='UTF-8' indent='yes'
+  doctype-public='-//W3C//DTD XHTML 1.1//EN'
+  doctype-system='http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd' />
+
 <!-- Returns true iff the given element is an XHTML span-level element -->
 <func:function name='f:element-is-span'>
   <xsl:param name='node' select='.' />
                        or f:element-is-span($node)' />
 </func:function>
 
+<!-- f:ends-with(a, b) returns true iff a ends with b -->
+<func:function name='f:ends-with'>
+  <xsl:param name='str' />
+  <xsl:param name='suffix' />
+  <func:result select='$suffix=substring($str,
+    string-length($str)-string-length($suffix)+1)' />
+</func:function>
+
 <!-- Remove leading whitespace from a string -->
 <func:function name='f:strip-leading'>
   <xsl:param name='str' select='.' />
index 02017a0304ed32bf31b535e1307d271636a76e98..2db5f35b3a3e83c537d1cc9ea5d66c89baa0b0a2 100644 (file)
@@ -41,7 +41,7 @@ files = {}
 
     files[f] = {
       sorttime: if t then t.to_f else 0.0 end,
-      displaytime: if t then t.getutc.strftime "%Y-%m-%d %H:%M UTC" end,
+      displaytime: if t then t.getutc.strftime "%Y-%m-%d %H:%M\u00a0UTC" end,
       displaysize: displaysize,
       size: size,
       type: type,
@@ -58,8 +58,8 @@ def render_entry(files, key)
   return <<~EOF
     <td>#{if f[:type]
       "<img alt='#{f[:type]}' width='16' height='16' src='#{case f[:type]
-            when :DIR; "/images/folder.svg"
-            when :UP;  "/images/return.svg"
+            when :DIR; "/icons/folder.svg"
+            when :UP;  "/icons/return.svg"
             else raise "no icon for filetype #{f[:type]}"
             end}' />"
     end}</td>
index 7eebab7c47dcdbc4412d2060ae0940354f55e1bb..f6c6ad6631be24db4250982d334c9fd8808f763c 100644 (file)
@@ -42,6 +42,11 @@ class CssCleanSelectorsFilter < Nanoc::Filter
                 next if ts[i].nil? or ts[i][:node] != :delim
                 next if ts[i][:value] == '*'
 
+                if ts[i-1]
+                    # keep whitespace before class selectors
+                    next if ts[i][:value] == '.' and ts[i-1][:node] == :whitespace
+                end
+
                 ts[i-1] = nil if ts[i-1] and ts[i-1][:node] == :whitespace
                 ts[i+1] = nil if ts[i+1] and ts[i+1][:node] == :whitespace
             end
diff --git a/lib/svg2png.rb b/lib/svg2png.rb
new file mode 100644 (file)
index 0000000..6147f2b
--- /dev/null
@@ -0,0 +1,35 @@
+# Nick's web site: svg2png filter: convert SVG to PNG using rsvg-convert.
+#
+# Copyright © 2021 Nick Bowler
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+class SVG2PNG < Nanoc::Filter
+    identifier :svg2png
+    type :text => :binary
+
+    def run(content, params = {})
+        args = ["-o", output_filename]
+        params.each do |key, val|
+            next if !val
+
+            args << "--#{key.to_s.gsub("_", "-")}"
+            if val != true then args << val.to_s end
+        end
+
+        dummy, status = Open3.capture2("rsvg-convert", *args,
+                                       stdin_data: content)
+        raise "rsvg-convert failed" if status != 0
+    end
+end
index 1c738d9e7447c374bce3c15d442ac9c801f8e14f..c110502ceee520bab6c2e0c435f222dc67a25760 100644 (file)
 
 class XhtmlCompatFilter < Nanoc::Filter
     identifier :xhtml_compat
+    requires 'nokogiri'
+
+    Xmlns = {
+        math: 'http://www.w3.org/1998/Math/MathML',
+        svg: 'http://www.w3.org/2000/svg',
+    }.freeze
+
+    XHTMLPublic = '-//W3C//DTD XHTML 1.1//EN'
+    MathPublic  = '-//W3C//DTD XHTML 1.1 plus MathML 2.0//EN'
+    MathSystem  = 'http://www.w3.org/Math/DTD/mathml2/xhtml-math11-f.dtd'
+    SVGPublic   = '-//W3C//DTD XHTML 1.1 plus MathML 2.0 plus SVG 1.1//EN'
+    SVGSystem   = 'http://www.w3.org/2002/04/xhtml-math-svg/xhtml-math-svg.dtd'
+
+    # XSLT 1.0 as implemented in Nokogiri canot construct doctypes based
+    # on content.  When using MathML or SVG elements in XHTML a different
+    # doctype is needed: select one based on which elements are present.
+    def fix_doctype(content, params = {})
+        return "#{content}" if not params[:fix_doctype]
+
+        doc = Nokogiri::XML(content)
+        doctype = doc.internal_subset
+
+        return "#{content}" if doctype.external_id != XHTMLPublic
+
+        if not doc.xpath("//svg:svg", Xmlns).empty?
+            doctype.remove
+            doc.create_internal_subset("html", SVGPublic, SVGSystem)
+        elsif not doc.xpath("//math:math", Xmlns).empty?
+            doctype.remove
+            doc.create_internal_subset("html", MathPublic, MathSystem)
+        end
+
+        return doc.to_xml
+    end
 
     def run(content, params = {})
+        text = fix_doctype(content, params)
+
         # Old versions of Netscape get confused by <hr/> but have no problem
         # with <hr />, so avoid that by adding spaces to such elements.
-        text = content.gsub(/([^[:space:]])\/>/m, '\1 />');
+        text.gsub!(/([^[:space:]])\/>/m, '\1 />');
 
         # Even older versions of Netscape interpret any script as Javascript,
         # which causes major problems with the CDATA hack; solve that by making
@@ -31,6 +67,8 @@ class XhtmlCompatFilter < Nanoc::Filter
         text.gsub!("<![CDATA[-->]]>", '\&*/')
 
         # Delete any zero-width word joiners added for XSLT processing.
-        return text.delete "\u2060"
+        text.delete! "\u2060"
+
+        return text
     end
 end