diff --git a/README.md b/README.md index 75512c0..c711add 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ are regenerated on each release — patches cannot be merged directly. Report problems (or send patches) to the author; accepted changes are applied to the development tree and appear in a following snapshot. -This snapshot was assembled from development commit `d6993110e9b3`. +This snapshot was assembled from development commit `9fa469dd74ed`. ## License diff --git a/com/kdesc.cpp b/com/kdesc.cpp index 5ad5fda..c1c314d 100644 --- a/com/kdesc.cpp +++ b/com/kdesc.cpp @@ -17,18 +17,18 @@ static void font_usage() { std::cout << "Font maintenance commands:\n" - " --font List the available fonts\n" - " --font list The same\n" - " --font samples Write a sample page of the available fonts\n" + " --typeface List the available fonts\n" + " --typeface list The same\n" + " --typeface samples Write a sample page of the available fonts\n" " to /index.html\n" - " --font samples \n" + " --typeface samples \n" " Write a sample page for the (not yet\n" " installed) font files under \n" - " --font install Install the font files found under\n" + " --typeface install Install the font files found under\n" " into the user font directory\n" - " --font install \n" + " --typeface install \n" " Install into instead\n" - " --font help This description\n" + " --typeface help This description\n" "\n" "Fonts are searched in the directories of the KLAMMERTEXT_FONTS\n" "environment variable (colon-separated; default $HOME/.klammertext/fonts)\n" @@ -141,7 +141,7 @@ static void font_command(const strings_t& words) } else if (verb == "help") { font_usage(); } else { - std::cout << "Unrecognized font command: --font " << join(words, " ") + std::cout << "Unrecognized typeface command: --typeface " << join(words, " ") << "\n\n"; font_usage(); } @@ -256,7 +256,7 @@ int main(int argc, char* argv[]) describe_rewrite_patterns(); } - // The --font subcommands operate on the Klammertext font store + // The --typeface subcommands operate on the Klammertext font store // (infrastructure) and load no klammer set. if (args.given("typeface")) { font_command(option_words(args, "typeface")); diff --git a/fnt/newgardineromni.css b/fnt/newgardineromni.css new file mode 100644 index 0000000..4c8b0d0 --- /dev/null +++ b/fnt/newgardineromni.css @@ -0,0 +1,7 @@ + +@font-face { + font-family: 'NewGardinerOmni'; + font-style: normal; + font-weight: 400; + src: url('newgardineromni/Regular.ttf') format('truetype'); +} diff --git a/fnt/newgardineromni/OFL.txt b/fnt/newgardineromni/OFL.txt new file mode 100644 index 0000000..33d8569 --- /dev/null +++ b/fnt/newgardineromni/OFL.txt @@ -0,0 +1,94 @@ +Copyright (c) 2020, Mark-Jan Nederhof https://mjn.host.cs.st-andrews.ac.uk, +with Reserved Font Name NewGardiner. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/fnt/newgardineromni/Regular.ttf b/fnt/newgardineromni/Regular.ttf new file mode 100644 index 0000000..d1db4e4 Binary files /dev/null and b/fnt/newgardineromni/Regular.ttf differ diff --git a/mac/font_store.cpp b/mac/font_store.cpp index 26b87a4..d670187 100644 --- a/mac/font_store.cpp +++ b/mac/font_store.cpp @@ -175,6 +175,15 @@ strings_t available_font_families() return result; } +bool font_installed(const std::string& name) +{ + const std::string wanted = name_to_dirname(name); + for (const std::string& family : available_font_families()) + if (name_to_dirname(family) == wanted) + return true; + return false; +} + static void extract_font_metrics(Resolved_font& font) { diff --git a/mac/font_store.h b/mac/font_store.h index 5b951b8..5512830 100644 --- a/mac/font_store.h +++ b/mac/font_store.h @@ -28,6 +28,12 @@ std::vector installed_font_dirs(); std::string default_font_dir(); // Every installable font family found across those directories. std::vector available_font_families(); +// Whether the store can resolve NAME -- written as the family name or as +// the store's directory name, since both resolve. The question a klammer +// asks before it names a typeface that is not shipped (a CJK face), so it +// can render the text plainly and say how to install the font instead of +// failing the document. Never throws; resolve_font() is the error path. +bool font_installed(const std::string& name); // The Locator is the DOCUMENT location to blame when the typeface is not // installed: without it the "not installed" error cites font_store.cpp, // a source location no user can act on (the no-.cpp-location rule). diff --git a/ref/reflect.cpp b/ref/reflect.cpp index 3fb2aa7..baef41d 100644 --- a/ref/reflect.cpp +++ b/ref/reflect.cpp @@ -4,6 +4,7 @@ #include "character.h" #include "error.h" #include "show.h" +#include "font_store.h" // extern "C" is needed for dlsym name lookup. Returning std::string from // C-linkage functions is technically non-standard but works correctly on @@ -21,8 +22,11 @@ std::string diacritics_examples([[maybe_unused]] Machine& machine) //std::cout << machine; std::stringstream kt {}; - kt << "@table :caption Diacritics (with typical base characters) :cell_hpos c c l |\n" - << "@i-Displayed | @i-Written | @i-Name ||\n"; + + kt << "@s2 Roman letters with a diacritical mark @\n\n"; + + kt << "@table :caption Diacritics (with typical base characters) :cell_hpos c c l :leading 1.2 |\n" + << "Displayed | Written | Name ||\n"; for (char symbol : diacritic_symbols_order) { std::string sym { symbol }; @@ -43,8 +47,10 @@ extern "C" std::string extended_latin_examples([[maybe_unused]] Machine& machine) { std::stringstream kt {}; - kt << "@table :caption Extended Latin characters and ligatures :cell_hpos c c l |\n" - << "@i-Displayed | @i-Written | @i-Name ||\n"; + kt << "@s2 Non-Roman characters @\n\n"; + + kt << "@table :caption Extended Latin characters and ligatures :cell_hpos c c l :leading 1.2 |\n" + << "Displayed | Written | Name ||\n"; for (const std::string& symbol : extended_latin_symbols) { auto [code, name] = extended_latin[symbol]; std::string coded = "^" + symbol + "^"; @@ -59,8 +65,10 @@ extern "C" std::string pinyin_examples([[maybe_unused]] Machine& machine) { std::stringstream kt {}; - kt << "@table :caption Mandarin pinyin tones (using vowel ``a'') :cell_hpos c c l |\n" - << " @i-Displayed | @i-Written | @i-Name ||\n"; + kt << "@s2 Chinese Pinyin syllable accents @\n\n"; + + kt << "@table :caption Mandarin pinyin tones (using vowel ``a'') :cell_hpos c c l :leading 1.2 |\n" + << "Displayed | Written | Name ||\n"; for (auto [symbol, codename] : pinyin_tones) { auto [code, name] = codename; @@ -79,33 +87,24 @@ std::string unicode_examples([[maybe_unused]] Machine& machine) << "To specify a wide variety of different letter forms, the Unicode Standard " << "defines a large set of \"code points\" organized by groups. To include a " << "Unicode character in Klammertext, you surround its hexadecimal number with " - << "the \"^^\" character. For example, the Cyrillic captial letter \"I\" is \"И\", " - << "included in Klammertext as ^^0418^^.\n\n" - << "To search for Unicode characters by their description, see: @nl@\n" + << "the \"^^\" character. For example, the Cyrillic capital letter \"I\" is \"И\", " + << "included in Klammertext as @t ^^0418^^ @.\n\n" + << "To search for Unicode characters by their description, see: " << "@link https://www.fileformat.info/info/unicode/char/search.htm @\n\n"; - kt << "@table :caption Examples of Chinese characters :cell_hpos c c l |\n" - << " Character | Written | Pinyin ||\n"; + kt << "@s3 Egyptian hieroglyphs @\n\n"; - for (auto chr : dao) { - std::string code = "^" + chr[1] + "^"; - kt << process_unicode_codepoint(code) << " | " - << "^" << code << "^" << " | " - << chr[0] << " ||\n"; - } - kt << "table@\n\n"; - - - kt << "@table :caption Examples of Egyptian hieroglyphs :cell_hpos c c c l |\n" + kt << "@table :caption Examples of Egyptian hieroglyphs :cell_hpos c c c l :leading 1.1 |\n" << "Hieroglyph | Written | Sound | Gardiner ||\n"; // The body font carries no hieroglyphs, so the glyph names the // typeface it needs (shipped in fnt/); @document declares and localizes // it. Under txt @typeface passes the text through unchanged. for (auto glyph : ptolemy) { - kt << "@typeface noto-sans-egyptian-hieroglyphs :scale 1.5 | " - << process_unicode_codepoint("^" + glyph[2] + "^") << " @ | " - << "^^" + glyph[2] + "^^ | " + std::string code = "^" + glyph[2] + "^"; + kt << "@egyptian " + << process_unicode_codepoint(code) << " @ | " + << "@t ^'" << code << "'^ @ | " << glyph[1] << " | " << glyph[0] << " ||\n"; } @@ -114,6 +113,34 @@ std::string unicode_examples([[maybe_unused]] Machine& machine) << "the Unicode codepoint in the second column of the \"List of hieroglyphs\" " << "table is the number in the ^^...^^ code used by Klammertext.\n\n"; + + kt << "@s3 Chinese characters @\n\n"; + + bool chinese = font_installed("Noto Serif SC"); + std::string target = machine.m_state.get("K_target").m_value; + const bool txt_or_ansi = target == "txt"|| target == "ansi"; + + if (txt_or_ansi || chinese) { + kt << "@table :caption Examples of Chinese characters :cell_hpos c c l :leading 1.2 |\n" + << " Character | Written | Pinyin ||\n"; + + for (auto chr : dao) { + std::string code = "^" + chr[1] + "^"; + kt << "@chinese " << process_unicode_codepoint(code) << " @ | " + << "@t ^'" << code << "'^ @" << " | " + << chr[0] << " ||\n"; + } + kt << "table@\n\n"; + } + if (!txt_or_ansi && !chinese) { + kt << "To display Chinese characters, you need to install a Chinese typeface, " + "which Klammertext does not ship. " + "Download @i Noto Serif SC @ from Google Fonts, unzip it, and run: \n\n" + " @t kdesc ^-^-typeface install @ ~ @i the-installed-font-directory @ \n\n" + "The font goes into your " + "@t ^~/.klammertext/fonts @ directory or the first directory in the list of " + "directories defined by the @t KLAMMERTEXT_FONTS @ environment variable.\n\n"; + } return kt.str(); } @@ -136,7 +163,7 @@ std::string argtype_definitions(Machine& machine) auto filename = argtype.m_loc.m_filename; if (contains(filename, "argtype_registry")) { std::string fill(name_width - name.size(), '~'); - kt << "\n@c " << name << " c@ @t " << fill << " t@ @i Pattern: i@ @c " + kt << "\n@c " << name << " c@ @t " << fill << " t@ @i Pattern: i@ ~ @c " << argtype.m_symbolic_pattern << " c@ |\n" << capitalize(trim(argtype.m_desc)) << " ||\n"; } else { diff --git a/sks/document/Makefile b/sks/document/Makefile index dcf69ab..4d164ba 100644 --- a/sks/document/Makefile +++ b/sks/document/Makefile @@ -51,7 +51,10 @@ document.so : $(LOCAL_OBJECTS) $(SKS_OBJECTS) $(LIBRARY) document.cpp | deps document.cpp -o $@ $(ALL_OBJECTS) -lklammertext $(LDLIBS) clean : - rm -f $(LOCAL_OBJECTS) $(LOCAL_DEPFILES) *.so *~ __pycache__ + rm -f $(LOCAL_OBJECTS) $(LOCAL_DEPFILES) *.so *~ + # document_txt.py is imported at render time (2026-09-18), so this + # directory now exists here; rm -f refused it and failed every clean. + rm -rf __pycache__ # Include generated dependency files (if they exist) -include $(LOCAL_DEPFILES) diff --git a/sks/document/document.k b/sks/document/document.k index 765b123..1caa74f 100644 --- a/sks/document/document.k +++ b/sks/document/document.k @@ -62,21 +62,10 @@ : Top-level document structure @@ -@@document.html,tex :: @eval :cpp *KLAMMERTEXT_HOME*/sks/document/document document @ @@ - -# The following definition of @document.txt is not adequate (it ignores :files, -# for example), but it enables tests of other txt klammers for now. When one of -# the arguments is empty, the justifcation postprocess should remove multiple -# lines, but __VSPACE__ inserts a space character, which prevents the lines -# removal from the justification function. You can also see the extra space -# before the *subtitle* value. +@@document.html,tex :: + @eval :cpp *KLAMMERTEXT_HOME*/sks/document/document document @ +@@ @@document.txt :: -@eval "*title*".upper() @ @nl@ -*subtitle* - -*author* @nl@ -*date* - -*text* + @eval document_txt.Document(K) @ @@ diff --git a/sks/document/document.py b/sks/document/document.py deleted file mode 100644 index 97f700e..0000000 --- a/sks/document/document.py +++ /dev/null @@ -1,23 +0,0 @@ -import kutil -import html_util -import tex_util - -class Document(klammer_base.Klammer_base): - def __init__(self, K): - super().__init__(K) - - def html(self): - if self.include_css == "true": - css_code, css_files = html_util.css(self.css, self._sks_enabled) - head = html_util.head(self.title, css_files=css_files, css_code=css_code) - else: - head = head = html_util.head(self.title, include_fonts=self.include_fonts == "true") - return html_util.page(head, self.body) - - def tex(self): - ragged_right = self.ragged_right in {"true", "True", "1"} - return tex_util.page( - self.title, self.subtitle, self.leading, self.pointsize, ragged_right, self.body) - - def txt(self): - return self.body diff --git a/sks/document/document_class.cpp b/sks/document/document_class.cpp index 3ded423..80cf285 100644 --- a/sks/document/document_class.cpp +++ b/sks/document/document_class.cpp @@ -340,9 +340,14 @@ std::string Document_class::typeface_preamble() std::stringstream ss {}; for (size_t i = 0; i < m_extra_typefaces.size(); i++) { const auto& [name, scale] = m_extra_typefaces[i]; + // Compared by directory name, as collect_typefaces() compares: a + // @typeface may write the family name or the store's directory + // name, and family_name holds what the css declares (2026-09-12), + // so a name-for-name comparison missed the directory spelling and + // left \ktface undefined in the .tex (found 2026-09-17). const Resolved_font* rf = nullptr; for (const auto& f : m_resolved_extra) - if (f.family_name == name) rf = &f; + if (f.dir_name == name_to_dirname(name)) rf = &f; if (!rf) continue; // \newfontfamily declares a switch command, unlike \setmainfont, // which changes the document's font permanently. @@ -351,22 +356,3 @@ std::string Document_class::typeface_preamble() } return ss.str(); } - -/* -void output_msg(const std::string& msg) -{ - std::cout << red; - (void)K::log(1, msg); - std::cout << black; -} - -void Document_class::create_directory(const std::string& directory) -{ - if (write_files) { - fs::create_directories(directory); - } else { - output_msg("Output directory: " + directory); - } -} - -*/ diff --git a/sks/document/document_html.cpp b/sks/document/document_html.cpp index e8d7a30..0b81691 100644 --- a/sks/document/document_html.cpp +++ b/sks/document/document_html.cpp @@ -1,3 +1,4 @@ +#include #include "util.h" #include "error.h" #include "log.h" @@ -118,6 +119,24 @@ std::string Document_class::font_definitions() ss << " --sans-serif: \"" << m_resolved_sans.family_name << "\", sans-serif;\n"; if (!m_mono_typeface.empty()) ss << " --monospace: \"" << m_resolved_mono.family_name << "\", monospace;\n"; + // The factor a @typeface span's font-size is multiplied by (2026-09-18). + // font.css renders the body at "font-size-adjust: ex-height 0.5", so + // the text the reader sees is 0.5 / x-height times its nominal size + // (EB Garamond, x-height 0.400: 1.25). A @typeface span switches + // the adjust OFF (a script with no x-height cannot be matched), so + // its ":scale N" was N times the NOMINAL size -- about a fifth + // smaller against the visible text than the same N in pdf, where + // fontspec's Scale= is against text rendered at nominal size. + // Folding the body's enlargement in here makes :scale mean the + // same thing in both targets. Emitted as a custom property the + // span multiplies in with calc(), so a fragment rendered without + // @document falls back to 1. SYNC: the anchor 0.5 is font.css's. + const float xheight_anchor = 0.5f; + if (m_resolved_serif.xheight_ratio > 0.0f) { + std::stringstream factor {}; + factor << std::setprecision(4) << xheight_anchor / m_resolved_serif.xheight_ratio; + ss << " --typeface_scale: " << factor.str() << ";\n"; + } ss << "}\n"; } // Font-size normalization across families is no longer emitted from diff --git a/sks/document/document_txt.py b/sks/document/document_txt.py new file mode 100644 index 0000000..6f3fa0f --- /dev/null +++ b/sks/document/document_txt.py @@ -0,0 +1,33 @@ +import kutil +import klammer_base +import phases + +class Document(klammer_base.Klammer_base): + def __init__(self, K): + super().__init__(K) + if self.files: + raise Exception("The :files option is not defined for the 'txt' target") + + def html(self): + raise Exception("Implemented by document.cpp") + + def tex(self): + raise Exception("Implemented by document.cpp") + + def txt(self): + # The document marker: section numbering under txt is a document + # service, and the numbering phase numbers only when it finds this + # at the head of the text (a fragment, as kdesc renders, has none). + result = phases.txt_document_marker() + "\n" + if self.title: + result += f"@b {self.title} @ @nl@\n" + if self.subtitle: + result += f"@b {self.subtitle} @ @nl@\n" + result += "\n" + if self.author: + result += f"@i {self.author} @ @nl@\n" + if self.date: + result += f"@i {self.date} @ @nl@\n" + result += "\n" + result += self.text + return result diff --git a/sks/font/font.k b/sks/font/font.k index c1aeb0b..832c26f 100644 --- a/sks/font/font.k +++ b/sks/font/font.k @@ -26,13 +26,28 @@ font as document text instead of setting anything. @typeface is the temporary, all-target replacement. ]# -@@typeface.k name | text :scale.float 1.0 : +@@typeface.k name | text :scale.float 1.0 :middle.bool :lift.length : A temporary change of typeface for *text*: the typeface *name* is resolved in the Klammertext font store (the directories of KLAMMERTEXT_FONTS, then the distribution's own), and the document declares and localizes it automatically -- writing this klammer is the -only declaration needed. *scale* multiplies the surrounding text size, -which a script whose glyphs are drawn small usually wants. +only declaration needed. *scale* multiplies the size of the surrounding +text as it appears, the same in html and pdf, which a script whose glyphs +are drawn small usually wants. + +*middle* centres *text* on the surrounding line instead of standing it on +the baseline -- for a script with no baseline relationship to the text, +such as hieroglyphs, whose font declares a tall ascender that would +otherwise open up every line the glyphs sit in. Html only: in tex a +row is already as tight as its tallest glyph. Never for a Latin +typeface, whose baseline must stay shared with the text around it. + +*lift* shifts *text* up by a length (down when negative), in em, pt or +px -- the correction for a font that draws its glyphs off the baseline, +which is a property of the font and so belongs in the klammer that names +it: Noto Sans Egyptian Hieroglyphs floats its signs and wants about +0.15em, NewGardinerOmni stands them on the baseline and wants none. +Html and tex alike; the txt target has no vertical position. The change ends with *text*: nothing after it is affected. Under the txt target a typeface cannot be expressed at all, so *text* is emitted @@ -107,9 +122,19 @@ than a typeface -- xeCJK for the tex path), so this klammer errors with the list of available typefaces until one is installed with "kdesc --typeface install". That is the honest behaviour: the klammer states what it needs and the store reports what it has. -]# @@cjk.k text : Set *text* in a typeface that supports CJK @@ @@cjk.html,tex,txt :: @typeface Noto Sans CJK SC | *text* @ @@ +]# + +# Only defining on Chinese style for now; could be parameterized. +@@chinese.k text :scale.float 1 : Set *text* in a Chinese font @@ +@@chinese.html,tex,txt :: @typeface Noto Serif SC :scale *scale* | *text* @ @@ + +# Egyptian hieroglyphs +@@egyptian.k text :scale.float 1.75 : Set *text* in an Egyptian hieroglyph font @@ +@@egyptian.html,tex,txt :: @typeface NewGardinerOmni :scale *scale* :middle | *text* @ @@ +# @@egyptian.html,tex,txt :: @typeface Noto Sans Egyptian Hieroglyphs :scale *scale* :middle | *text* @ @@ + @@fraktur.k s : Set text *s* in Fraktur @@ @@fraktur.tex :: \textfrak{*s*} @@ diff --git a/sks/font/font.py b/sks/font/font.py index d73e9a7..ab3d1b8 100644 --- a/sks/font/font.py +++ b/sks/font/font.py @@ -60,9 +60,38 @@ class Typeface(klammer_base.Klammer_base): # this sits inside a double-quoted html attribute. What is quoted # here is the MARKER: @document replaces it with the resolved # family name (see the marker note above). + # The size is the scale times the body's own enlargement: the body + # is rendered at "font-size-adjust: ex-height 0.5" (font.css), so + # the visible text is larger than its nominal size, and a span + # with the adjust off at a plain "Nem" sat about a fifth smaller + # against it than the same :scale in pdf. @document emits the + # factor as --typeface_scale (document_html.cpp, font_definitions); + # the fallback 1 is for a fragment rendered without @document. + # The "*" of calc() survives the @eval re-read (tested 2026-09-18); + # the "--" of the property name is hidden below with the rest. style = "', serif; font-size-adjust: none" - if float(self.scale) != 1.0: - style += f"; font-size: {float(self.scale)}em" + style += f"; font-size: calc({float(self.scale)}em * var(--typeface_scale, 1))" + # :middle -- for a script with no baseline relationship to the + # text. A browser sizes the span's inline box from the FONT's + # ascender and descender, not the glyph's ink, and centres it on + # the content area's midpoint; a hieroglyph font declares an + # ascender of 1.0 em and no descender (NewGardinerOmni; Noto: + # 1.324/-0.326), so at :scale 1.75 the box sat half an em too + # high and every table row grew from 31 to 47 px (Noto: 62). + # line-height 1 drops the body's leading from the enlarged box and + # vertical-align middle centres it on the PARENT's x-height, which + # is why this uses the text's metrics, not the script font's zeros. + # Measured 2026-09-18: 40 px, the floor the ink allows; line-height + # 0 alone gave 31 px with the glyphs overlapping the rows above. + # Off by default: a Latin typeface must keep the shared baseline. + if self.middle: + style += "; line-height: 1; vertical-align: middle" + if self.lift_length(): + # Relative positioning moves the ink and nothing else: the + # line box is laid out as if the span had not moved, which is + # what a baseline correction wants (vertical-align would + # re-lay the line). Negative top = up, as \raisebox in tex. + style += f"; position: relative; top: -{self.lift_length()}" # The STYLE is markup, so its typographically active characters are # hidden: the html target's transforms turn a single quote into a # curly one, which broke the css font-family value. The MARKER is @@ -81,7 +110,27 @@ class Typeface(klammer_base.Klammer_base): # makes the change temporary (the retired @font emitted a bare # \setmainfont, which changed the font for the rest of the # document). - return "{" + typeface_marker(self.name, self.scale) + " " + self.text + "}" + group = "{" + typeface_marker(self.name, self.scale) + " " + self.text + "}" + if self.lift_length(): + # A \raisebox produced HERE is safe from the generator escaping + # pass: this klammer is applied after a generator's result has + # been escaped, so its backslash is never swept -- the trap + # @nl.tex fell into (2026-09-07) is a klammer applied inside a + # generator's ARGUMENT, which @typeface shares with @b and @i. + return "\\raisebox{" + self.lift_length() + "}" + group + return group + + def lift_length(self): + # :lift, validated to the units both targets can move by. The + # length argtype admits w/h fractions and a quoted string, which + # mean nothing as a vertical shift; a bare number would be columns + # or lines and is refused for the same reason. + lift = self.lift + if not lift: + return "" + if not re.fullmatch(r"-?(\d+\.?\d*|\.\d+)(em|pt|px)", lift): + raise Exception(f"@typeface :lift takes a length in em, pt or px, not '{lift}'") + return lift def txt(self): # A typeface cannot be expressed in plain text. Declared in the diff --git a/sks/image/image.py b/sks/image/image.py index 5a1d7b7..6c2702c 100644 --- a/sks/image/image.py +++ b/sks/image/image.py @@ -19,8 +19,7 @@ class Image(klammer_base.Klammer_base): id = 0 def __init__(self, K, as_string=True, width=None): super().__init__(K) - # pprint.pprint(self.__dict__) - # self.show() + # /dev/shm is a fast RAM-backed tmpfs on Linux; on macOS it does not # exist, so fall back to the platform temp directory. cache_dir = "/dev/shm" @@ -47,22 +46,12 @@ class Image(klammer_base.Klammer_base): def html(self): img_dir = f"{self.K_output_dir}/{self.K_output_basename}/{self.Image_output_dir}" - - # output_dir = self.K_output_dir or "." - # input_basename = self.__dict__.get("K_input_name", "") - # input_basename = "/" + input_basename if input_basename else "" - # img_dir = f"{output_dir}/{self.image_output_dir}{input_basename}" - #print("img_dir:", img_dir) if not os.path.exists(img_dir): os.makedirs(img_dir) if not os.path.exists(f"{img_dir}/{os.path.basename(self.source)}"): shutil.copy2(self.source, img_dir) - # width, scale_x, scale_y = kutil.parse_length( - # "html", - # self.width if self.width != "none" else self.image_default_width) - scale_x, scale_y = 1, 1 # Old result = E("img") @@ -72,9 +61,6 @@ class Image(klammer_base.Klammer_base): filename = f"{self.Image_output_dir}/{os.path.basename(self.source)}" result.attr("src", filename).attr("alt", os.path.basename(filename)) - #if self.width: - - # kutil.msg("rel: " + str(self.rel_fraction)) length, value, ltype = kutil.parse_width("html", self.width, self.rel_fraction) if ltype == "w": @@ -90,19 +76,6 @@ class Image(klammer_base.Klammer_base): if self.border: result.cls("image_border") - # print(result) - - #result.attr("style", f"width: {width}") - # if width and width != "none": - # result.attr("style", f"width: {width}") - # if width == "none": - # if scale_x: - # result.attr("style", "width: 0px;") - # result.attr("scale-x", scale_x) - # elif scale_y: - # result.attr("style", "height: 0px;") - # result.attr("scale-y", scale_y) - if self.file_error: self.caption += self.file_error_message @@ -120,14 +93,7 @@ class Image(klammer_base.Klammer_base): def tex(self): - # kutil.msg(f"number: {self.number}") - #selfsource, width, height = self.cache.get(self.K_target, self.basename) source = os.path.abspath(self.source) - #if self.abswidth != 0: - # width = f"{self.abswidth}\\paperwidth" - #if self.caption or self.number: - # width = "\\textwidth" - #else: width = kutil.parse_width("tex", self.width, self.rel_fraction)[0] width = re.sub("px", "pt", width) #result = f'\\includegraphics[width={width}]{{{source}}}' @@ -138,8 +104,6 @@ class Image(klammer_base.Klammer_base): result = latex_util.add_caption( result, "Figure", self.number, self.caption, width, self.hpos, self.caption_side, offset=self.offset) - #result, "Figure", self.number, self.caption, self.hpos, self.caption_side, - #self.width, self.caption_side_center, self.vmargin, self.caption_margin) else: result = f'\\includegraphics[width={width}]{{{source}}}' result = latex_util.caption_wrapper(result, self.hpos, offset=self.offset) @@ -150,8 +114,3 @@ class Image(klammer_base.Klammer_base): # box material: it must sit in vertical mode or it is typeset beside # any text it follows. return latex_util.block(result) - - -# Better to cause an error; there's no good text substitute for an image -# def txt(self): -# return f"[Image: {self.basename}\nCaption: {self.caption}]" diff --git a/sks/kutil/klammer_base.py b/sks/kutil/klammer_base.py index 2ee9bb9..6d0be8b 100644 --- a/sks/kutil/klammer_base.py +++ b/sks/kutil/klammer_base.py @@ -1,4 +1,3 @@ -import pprint import re import kutil @@ -41,25 +40,18 @@ def hide_typographic(s): class Klammer_base: def __init__(self, K): - #args = {k:escape(v) for k, v in K.__dict__.items() args = {k:v for k, v in K.__dict__.items() if not k.startswith('__')} for key in args: setattr(self, key, args[key]) - #setattr(self, "_klammer_name", klammer_name) - #pprint.pprint(self.__dict__) - def html(self): - #return '[{}: HTML]'.format(self.__class__.__name__) return None def tex(self): - #return '[{}: LaTeX]'.format(self.__class__.__name__) return None def txt(self): - #return '[{}: Plain text]'.format(self.__class__.__name__) return None def show(self, label=""): diff --git a/sks/list/list.py b/sks/list/list.py index 3d671c3..9cde145 100644 --- a/sks/list/list.py +++ b/sks/list/list.py @@ -29,7 +29,8 @@ class List(klammer_base.Klammer_base): body = "\n\n".join([f"\\item {e}" for e in self.items]) command = {'ol' : 'enumerate', 'ul' : 'itemize'}[self.list_type] init = "" - if self.__dict__.get("initial") and self.initial != 1: + # :initial is @ol's alone (list.k); a @ul has no such attribute. + if getattr(self, "initial", 1) != 1: init = "\\addtocounter{enumi}{" + str(self.initial - 1) + "}" #topsep = '[noitemsep,topsep=0pt]' topsep = "[noitemsep,topsep=0pt," if self.compressed else "[topsep=0pt,itemsep=2pt," diff --git a/sks/section/section.k b/sks/section/section.k index 9d6328d..fc7208f 100644 --- a/sks/section/section.k +++ b/sks/section/section.k @@ -1,6 +1,11 @@ # Book-specific component headings (others possible to correspond to # the front and back matter of a book): +@@section_args.o :id :n.int -1 : + Common arguments for section headings. If *n* is not the default, it is used + as the section number. +@@ + @@preface.k :id :name Preface : Preface text @@ @@preface.html,tex,txt :: @eval section.Section(K, "preface") eval@ @@ @@ -9,30 +14,30 @@ @@part.k title :id :name Part : Part heading @@ @@part.html,tex,txt :: @eval section.Section(K, 0) eval@ @@ -@@s1.k title :id :n : Top level division, numbered @@ +@@s1.k title @section_args@ : Top level division, numbered @@ @@s1.html,tex,txt :: @eval section.Section(K, 1) eval@ @@ -@@s2.k title :id :n : Second level division, numbered @@ +@@s2.k title @section_args@ : Second level division, numbered @@ @@s2.html,tex,txt :: @eval section.Section(K, 2) eval@ @@ -@@s3.k title :id :n : Third level division, numbered @@ +@@s3.k title @section_args@ : Third level division, numbered @@ @@s3.html,tex,txt :: @eval section.Section(K, 3) eval@ @@ -@@s4.k title :id :n : Fourth level division, numbered @@ +@@s4.k title @section_args@ : Fourth level division, numbered @@ @@s4.html,tex,txt :: @eval section.Section(K, 4) eval@ @@ -@@s5.k title :id :n : Fifth level division, numbered @@ +@@s5.k title @section_args@ : Fifth level division, numbered @@ @@s5.html,tex,txt :: @eval section.Section(K, 5) eval@ @@ -@@s6.k title :id :n : Sixth level division, numbered @@ +@@s6.k title @section_args@ : Sixth level division, numbered @@ @@s6.html,tex,txt :: @eval section.Section(K, 6) eval@ @@ -@@s7.k title :id :n : Seventh level division, numbered @@ +@@s7.k title @section_args@ : Seventh level division, numbered @@ @@s7.html,tex,txt :: @eval section.Section(K, 7) eval@ @@ # Unnumbered section headings: -@@h1.k title :id :n : Top level division, unnumbered @@ +@@h1.k title :id : Top level division, unnumbered @@ @@h1.html,tex,txt :: @eval section.Section(K, 1, numbered=False) eval@ @@ @@h2.k title :id : Second level division, unnumbered @@ diff --git a/sks/section/section.py b/sks/section/section.py index f637e6c..b28a8e8 100644 --- a/sks/section/section.py +++ b/sks/section/section.py @@ -1,5 +1,6 @@ import kutil import klammer_base +import phases NUMBER_VAR = "___NUM___" @@ -48,7 +49,6 @@ class Section(klammer_base.Klammer_base): # names = "part chapter section subsection subsubsection paragraph subparagraph subsubparagraph subsubsubparagraph".split() names = "part sA sB sC sD sE".split() - #self.show() if self.id: id = self.id else: @@ -56,13 +56,8 @@ class Section(klammer_base.Klammer_base): Section.label_number += 1 result = '' - if self.__dict__.get("n") and self.n: + if getattr(self, "n", -1) > -1: # absent for @part and @h1..@h7 result += f'\\setcounter{{chapter}}{{{int(self.n)-1}}}\n' - #if self.id: - # result += - #if self.level == 1: - #result += '\\newpage\n\n' - # result += '\\vspace*{24pt}' suffix = "" if self.numbered else "*" result += f'\\{names[self.level]}{suffix}{{{self.title}}}' result += f'\n\\hypertarget{{{id}}}{{}}' @@ -73,4 +68,18 @@ class Section(klammer_base.Klammer_base): return result def txt(self): - return f"@b {self.title} @" + # The number is a marker phases.txt_insert_section_numbers resolves + # -- to a number inside a @document, to nothing outside one -- so + # the space after it is the marker's, not the title's. A preface + # and an unnumbered heading carry no marker at all; a part carries + # a level-0 marker and its name, as the html "Part" prefix. + if self.preface: + return f"@b {self.name} @" + if not self.numbered: + return f"@b {self.title} @" + # :n comes from section_args, which @part and the @h headings do + # not declare, so it is absent there rather than -1. + marker = phases.txt_section_marker(self.level, getattr(self, "n", -1)) + if self.level == 0: + return f"@b {marker} {self.name} {self.title} @" + return f"@b {marker} {self.title} @" diff --git a/sks/table/css/table.css b/sks/table/css/table.css index 5902bc6..81d9eff 100644 --- a/sks/table/css/table.css +++ b/sks/table/css/table.css @@ -12,8 +12,13 @@ tr:first-child { td { - /* padding-left: 1rem; */ - padding: .1rem .5rem .2rem .5rem; + /* Vertical space is the table's line-height (:leading, set on the + by table.py -- the html equivalent of \arraystretch), so no + vertical padding here: it was an html-only extra that kept rows + looser than the pdf's. Horizontal padding is :colsep, the same + value that becomes \tabcolsep; the fallback is for a table that + reaches this rule without the property. */ + padding: 0 var(--colsep, 4pt); vertical-align: top; } diff --git a/sks/table/table.py b/sks/table/table.py index d974525..a34b9ab 100644 --- a/sks/table/table.py +++ b/sks/table/table.py @@ -575,6 +575,24 @@ class Table(klammer_base.Klammer_base): # would wrap it as a stray

inside the table. return E("colgroup").body(cols.strip(), newline=False).str(), layout, table_width + def html_style(self): + # :leading and :colsep under html (2026-09-18; both were tex-only). + # \arraystretch multiplies the row strut, which is \baselineskip, + # 1.2 times the font size -- so :leading L is a 1.2 L em row, and + # the html equivalent is that line-height on the table, inherited + # by every cell. The cells' vertical padding went with it + # (table.css): an html-only extra with no tex counterpart, which + # kept a :leading 1 table 0.3rem looser per row than its pdf. + # :colsep is a custom property the td rule reads for its horizontal + # padding (padding does not inherit); the flush-edge classes still + # zero the outer ones. Measured 2026-09-18: rows of :leading 1 and + # :leading 2 in the ratio 1:2 in both targets. + line_height = f"{1.2 * float(self.leading):.4g}" + # Hidden: the result is re-read and the html target's transforms + # would turn the property's "--" into an en dash (font.py's trap). + return klammer_base.hide_typographic( + f"line-height: {line_height}; --colsep: {self.colsep}") + def html(self): result = "" for row_i, row in enumerate(self.cells): @@ -592,7 +610,7 @@ class Table(klammer_base.Klammer_base): # algorithm), matching the tex \ratio division. The max-width # cap goes on the caption wrapper when there is one (the table's # own percentage would be circular in a shrink-to-fit wrapper). - result = E("table").body(result) + result = E("table").body(result).attr("style", self.html_style()) if self.number or self.caption: result = html_util.add_caption( result, "Table", self.number, self.caption, @@ -605,7 +623,7 @@ class Table(klammer_base.Klammer_base): result = html_util.hpos_container(result, self.hpos, self.offset).str() return result colgroup, layout, table_width = self.html_colgroup() - result = E("table").body(colgroup + result) + result = E("table").body(colgroup + result).attr("style", self.html_style()) if self.number or self.caption: # The caption wrapper carries the width (a percentage on the # shrink-to-fit wrapper itself would be circular) and the table diff --git a/sks/target/phases.py b/sks/target/phases.py index d571b50..e57ff2e 100644 --- a/sks/target/phases.py +++ b/sks/target/phases.py @@ -248,6 +248,50 @@ def txt_format_footnotes(text, K): + label + " " + footnote + "\n\n") return result + +# Section numbering under txt (2026-09-18). Numbering is a DOCUMENT +# service, as it is in html (a pass @document runs over the page) and tex +# (LaTeX's own counters inside the document): a heading rendered outside +# a @document -- kdesc's renders of the ref/ generators -- is not +# numbered. Document.txt() (sks/document/document_txt.py) opens its +# result with the document marker; Section.txt() emits a section marker +# carrying the level and the :n override; this phase, first in the txt +# and ansi lists, numbers the headings when the document marker is there +# and strips every marker either way. A heading at a level with no +# parent numbers as "0.1", as html and tex print it -- the txt target +# does not diverge from them. The single-source functions are the +# TODO #47 pattern. + +def txt_document_marker(): + return "__KTEXTDOCUMENT__" + +def txt_section_marker(level, n=-1): + # LEVEL 0 is a part, 1-7 the sections; N > -1 sets the number. + return f"__KTEXTSECTION__{level}__{n}__" + +def txt_insert_section_numbers(text, K): + # The marker is the first thing the document emits, but the result + # reaches the phase behind whatever newlines the apply left in front. + head = text.lstrip() + in_document = head.startswith(txt_document_marker()) + if in_document: + text = head[len(txt_document_marker()):].lstrip("\n") + levels = [0] * 8 # 0: parts; 1-7: sections (parts do not reset them, as in LaTeX) + marker_rgx = re.compile(r"__KTEXTSECTION__(\d+)__(-?\d+)__ ?") + def number(match): + if not in_document: + return "" + level, n = int(match.group(1)), int(match.group(2)) + levels[level] = n if n > -1 else levels[level] + 1 + if level == 0: + return f"{levels[0]} " # a part resets nothing below it + for i in range(level + 1, len(levels)): + levels[i] = 0 + return ".".join(str(v) for v in levels[1:level + 1]) + " " + return marker_rgx.sub(number, text) + +# HTML + def html_footnote_marker(): return "kt-footnote" diff --git a/sks/target/target.k b/sks/target/target.k index 9a3bc1b..fd56109 100644 --- a/sks/target/target.k +++ b/sks/target/target.k @@ -63,6 +63,7 @@ of this: neither the transforms nor the quoting reach it. ' ' | ~ __KTEXTNBSP__ :after_apply + phases.txt_insert_section_numbers ; phases.txt_format_footnotes ; phases.txt_insert_captions ; phases.txt_justify_blocks ; @@ -83,6 +84,7 @@ of this: neither the transforms nor the quoting reach it. @@@target ansi | Plain text with ANSI escape sequences for type styles :includes txt :after_apply + phases.txt_insert_section_numbers ; phases.txt_format_footnotes ; phases.txt_insert_captions ; phases.txt_justify_blocks ; diff --git a/tst/kdesc_test.sh b/tst/kdesc_test.sh index 9615e77..1253fe2 100755 --- a/tst/kdesc_test.sh +++ b/tst/kdesc_test.sh @@ -8,7 +8,7 @@ # * a flag a user reaches for often gets a single letter (-k klammers, # -t targets, -c character codes, -i input); a more specialised topic gets # a multi-letter name (--argtypes, --katoms, --rewrite, --optionsets, -# --coverage, --klammersets, --font); +# --coverage, --klammersets, --typeface); # * -v says how much to show about the command's PROCESSING and never what # its RESULT contains. So the katom regex column is "--katoms full" and # the coverage file column is "--coverage all", not verbosity levels. @@ -151,6 +151,11 @@ shows "24. --coverage help explains" 'Coverage commands' "$KDESC" --cover shows "25. --katoms help explains" 'Katom commands' "$KDESC" --katoms help shows "26. an unknown coverage word" 'Unrecognized coverage command' "$KDESC" --coverage nonsense shows "27. an unknown katom word" 'Unrecognized katom command' "$KDESC" --katoms nonsense +# The help text and the error must name the flag by its CURRENT spelling: +# both still said --font for three weeks after the 2026-08-25 rename, and # retired-ok +# the retired-name guard's entries did not match the bare word. +shows "27a. --typeface help names --typeface install" '--typeface install' "$KDESC" --typeface help +shows "27b. an unknown typeface word names the flag" 'Unrecognized typeface command: --typeface' "$KDESC" --typeface nonsense echo echo "-- provenance: -i shows what the INPUT defines --"