Verbatim-safe typography, ^-punctuation quoting, full-range ^UUUU^, polyglot html

Typographic transforms (---, quote pairs, ~) no longer touch verbatim
text: @c/@code/@source_listing content and ^'...'^ spans show exactly the
characters written. "^" before any punctuation character quotes it in
every target (the apostrophe excepted: ^' opens a literal span), with the
new :resolve option on @@@target declaring per-target renderings. The
^UUUU^ code-point form accepts 4-6 hex digits, the full Unicode range.
The html output and transform spellings are polyglot (XML-valid), in
preparation for an EPUB target. New suites: transform_test, character_test
(engine), typography_test (SKS).

(from dev 07ce5ea86a0a)
This commit is contained in:
2026-08-23 20:48:26 +02:00
parent d982c0d6cc
commit 37b6ba1c4f
77 changed files with 1665 additions and 1608 deletions

View File

@@ -101,7 +101,7 @@ std::string tex_to_pdf(Machine& machine)
ss << " " << line << "\n";
}
ss << "Check log file: " << outbase << ".log";
throw Definition_error(ss.str(), Locator(), false);
throw Definition_error(ss.str(), Locator());
}
if (std::regex_search(xelatex_log, std::regex("Package rerunfilecheck Warning:"))) {
(void)K::log(1, "Rerunning xelatex because document structure has changed");
@@ -115,7 +115,7 @@ std::string tex_to_pdf(Machine& machine)
ss << " " << line << "\n";
}
ss << "Check log file: " << outbase << ".log";
throw Definition_error(ss.str(), Locator(), false);
throw Definition_error(ss.str(), Locator());
}
}
warn_wide_tables(xelatex_log);

View File

@@ -63,3 +63,20 @@
@@
@@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.txt ::
@eval "*title*".upper() @ @nl@
*subtitle*
*author* @nl@
*date*
*text*
@@

View File

@@ -16,9 +16,9 @@ bool strbool(const std::string& s, const Locator& loc)
std::vector<std::string> values = {"false", "False", "0", "true", "True", "1"};
if (is_not_in(s, values)) {
std::stringstream ss {};
ss << "The value \"" << s << "\" is not a Boolean values. Possible values are:\n"
ss << "The value \"" << s << "\" is not a Boolean value. Possible values are:\n "
<< join(values, ", ");
throw Argument_error(ss.str(), loc, false);
throw Argument_error(ss.str(), loc);
}
bool result = (find(values.begin(), values.end(), s) - values.begin()) > 2;
return result;
@@ -51,10 +51,15 @@ Document_class::Document_class(Machine& machine) : Klammer_base(machine)
// spaces, ~ expansion (resolve_filename_list in mac/file.cpp); the
// existence checks resolve relative names against the input directory,
// as parse_input_filename() will.
m_files = resolve_filename_list(get("files"), get("K_input_dir"));
// Filename-bearing values arrive ESCAPED for the target (the state
// stores escaped values so they flow correctly into output); a filename
// is programmatic use, so decode the markers first -- the C++ mirror of
// the Python-side unescape_ktesc() rule. Found 2026-08-22: under tex,
// ":files my_chapter" searched for "myKTESC005fKTESCchapter".
m_files = resolve_filename_list(ktesc_resolve(get("files")), get("K_input_dir"));
m_css_text = get("css_text");
m_css_filenames = resolve_filename_list(get("css_files"), get("K_input_dir"));
m_css_filenames = resolve_filename_list(ktesc_resolve(get("css_files")), get("K_input_dir"));
m_include_sks_css = strbool(get("include_sks_css"), loc);
frame_background_color = get("frame_background_color");
frame_text_color = get("frame_text_color");
@@ -62,7 +67,7 @@ Document_class::Document_class(Machine& machine) : Klammer_base(machine)
nav_text_color = get("nav_text_color");
js_text = get("js_text");
m_js_filenames = resolve_filename_list(get("js_files"), get("K_input_dir"));
m_js_filenames = resolve_filename_list(ktesc_resolve(get("js_files")), get("K_input_dir"));
m_include_sks_js = strbool(get("include_sks_js"), loc);
// font_dirs = word_split(get("font_dirs"));
@@ -138,33 +143,49 @@ void Document_class::save_string_input_as_file()
fs::path parse_input_filename(const std::string& s, const std::string& input_dir)
{
// The two-class rule (2026-08-22), replacing a three-stage search whose
// second stage could quietly shadow a file beside the document:
//
// * a BARE WORD -- no directory separator, no extension -- is the kt/
// SHORTCUT: ":files X" MEANS kt/X.kt in the root file's directory,
// and nothing else. Missing is an immediate error whose message
// teaches the convention, not a fallback. The kt/ directory is the
// conventional home for a document's input files, and putting the
// meaning entirely in the name lets several root documents share it.
//
// * anything else is a real PATHNAME, absolute or resolved against the
// input file's directory (K_input_dir), so a document renders
// identically wherever ktext is run from.
//
// Which file a name landed on is a DERIVED value, so "-v 1" reports it.
fs::path p(s);
bool bare = s.find('/') == std::string::npos && p.extension().empty();
if (bare) {
fs::path in_kt = fs::path(input_dir) / "kt" / (s + ".kt");
if (!file_exists(in_kt.string())) {
throw Argument_error(
"The \":files\" name \"" + s + "\" is a bare word, which by "
"convention means the file kt/" + s + ".kt in the root "
"document's directory:\n"
" " + in_kt.string() + "\n"
"That file does not exist. Create it there, or write a real "
"pathname (a name with a directory or the \".kt\" extension) "
"to use a file elsewhere.",
Locator::none());
}
// The resolved path itself shows kt/ -- no label needed.
(void)K::log(1, "Input file \"" + s + "\": " + in_kt.string());
return in_kt;
}
if (p.extension() != ".kt") {
p += ".kt";
}
if (p.is_absolute()) {
return p;
}
// A relative :files name resolves against the input file's directory
// (K_input_dir), so a document renders identically wherever ktext is
// run from; then the legacy kt/ subdirectory; a name found in neither
// is returned as given (cwd-relative) and errors downstream.
// Which of the three a name landed on is a DERIVED value -- the ".kt" may
// have been supplied, and the directory certainly was -- so "-v 1" reports
// it. A ":files chapter1" that quietly found kt/chapter1.kt rather than
// the file beside the document is exactly what the author cannot see.
fs::path in_input_dir = fs::path(input_dir) / p;
if (file_exists(in_input_dir.string())) {
(void)K::log(1, "Input file \"" + s + "\": " + in_input_dir.string());
return in_input_dir;
}
fs::path in_kt_dir = fs::path(input_dir) / "kt" / p;
if (file_exists(in_kt_dir.string())) {
(void)K::log(1, "Input file \"" + s + "\": " + in_kt_dir.string()
+ " (found in the kt/ subdirectory)");
return in_kt_dir;
}
return p;
(void)K::log(1, "Input file \"" + s + "\": " + in_input_dir.string());
return in_input_dir;
}
void Document_class::write(const std::string& filename, const std::string& contents)

View File

@@ -113,38 +113,14 @@ std::string Document_class::font_definitions()
ss << " --monospace: \"" << m_mono_font << "\", monospace;\n";
ss << "}\n";
}
// Emit scale factors so sans and mono fonts match the serif font.
// Three scaling methods (uncomment the desired one):
// x-height: serif_xh / other_xh (matches lowercase, like fontspec MatchLowercase)
// cap-height: serif_ch / other_ch (matches capitals)
// average: mean(serif_xh,serif_ch) / mean(other_xh,other_ch) (compromise)
float serif_xh = m_resolved_serif.xheight_ratio;
float serif_ch = m_resolved_serif.capheight_ratio;
float serif_avg = (serif_xh + serif_ch) / 2.0f;
if (serif_avg > 0.0f) {
auto scale = [&](const Resolved_font& other) -> std::string {
float other_avg = (other.xheight_ratio + other.capheight_ratio) / 2.0f;
if (other_avg > 0.0f && other_avg != serif_avg) {
char buf[16];
// float ratio = serif_xh / other.xheight_ratio; // x-height
// float ratio = serif_ch / other.capheight_ratio; // cap-height
float ratio = serif_avg / other_avg; // average
std::snprintf(buf, sizeof(buf), "%.4f", ratio);
return buf;
}
return "";
};
std::string sans_scale = scale(m_resolved_sans);
std::string mono_scale = scale(m_resolved_mono);
if (!sans_scale.empty() || !mono_scale.empty()) {
ss << ":root {\n";
if (!sans_scale.empty())
ss << " --sans-serif-scale: " << sans_scale << ";\n";
if (!mono_scale.empty())
ss << " --monospace-scale: " << mono_scale << ";\n";
ss << "}\n";
}
}
// Font-size normalization across families is no longer emitted from
// here (2026-08-22): font.css declares "font-size-adjust: ex-height 0.5"
// on body, and the browser renders every font at the same x-height --
// the same computation the former --sans-serif-scale/--monospace-scale
// factors did from build-time OS/2 metrics, but applied to EVERY family
// switch instead of the four CSS sites that remembered to multiply.
// The metric extraction in the font store remains (the tex path and
// kdesc --font still use it).
// Global font scale: applied to body font-size
if (m_font_scale != 1.0f) {
char buf[16];
@@ -368,7 +344,15 @@ Document_class::insert_section_numbers(const std::string& marker, bool add_to_to
{
std::vector<int> levels(9, 0);
std::smatch match {};
std::string pattern = R"(<([\w-]+)\s*(.*?)>(.*?)MARKER\s*</span>\s*(.*?)<.*)";
// The heading text (group 4) runs to the heading element's OWN closing
// tag (the \1 backreference), not to the first "<": a nested element in
// a section title -- @c's <span class="code">, an @i's <em> -- would
// otherwise end the capture early and silently truncate the title in
// BOTH tables of contents (article's single_page_toc and book's
// navigation TOC read the same capture). Found 2026-08-22 via a @c in
// an @s2 title; heading.cpp's section_rgx already used the
// close-on-own-tag idiom.
std::string pattern = R"(<([\w-]+)\s*(.*?)>(.*?)MARKER\s*</span>\s*(.*?)</\1>.*)";
pattern = string_replace(pattern, "MARKER", marker);
std::regex heading_rgx(pattern);
std::vector<std::pair<std::string, std::string>> modified_components {};

View File

@@ -38,104 +38,6 @@ std::string levels_to_section(std::vector<unsigned int> levels)
int part_number = 1;
int unnumbered_id = 0;
/*
std::tuple<std::string, std::vector<Heading>, std::vector<unsigned int>, unsigned int>
add_section_numbers(const std::string& s, const std::string& basename, std::vector<unsigned int> levels, unsigned int initial_id,
std::map<std::string, std::string>& section_id_map)
{
(void)K::log(3);
auto depth { levels.size() };
std::vector<Heading> headings {};
std::string text { s };
std::regex section_rgx (R"((.*?)<h(\d)(.*?)>(.*?)</h\2>)");
std::regex part_rgx (R"((.*?)<kt-part(.*?)>Part (\d+)\s*<br>\s*(.*?)\s*</kt-part>)");
std::regex id_rgx(R"((.*?)id=\"([-\w]+)\"(.*))");
unsigned int id_number { initial_id };
//std::string result {};
std::stringstream result {};
for (std::string line : regex_split(s, std::regex(R"(\n)"), false)) {
std::smatch match {};
if (std::regex_match(line, match, part_rgx)) {
std::string pre { match[1] };
std::string attr { match[2] };
std::string level { match[3] };
std::string title { match[4] };
std::string id_prefix { "_part_"};
std::string id {};
std::smatch id_match {};
if (std::regex_match(attr, id_match, id_rgx)) {
id = id_match[2];
} else {
id = id_prefix + std::to_string(part_number);
}
//title = "Part " + std::to_string(part_number) + " - " + title;
//std::string section = "Part " + level;
std::string section = "Part " + std::to_string(part_number);
elements_t part_title
{ html::elt("kt-part",
{ html::elt("span", section).attr("class", "sectionnumber"),
html::elt("span", trim(title)).attr("class", "sectiontitle") })
.attr("id", id)
.attr("data-level", level) };
result << pre << part_title << "\n";
headings.push_back(Heading(0, basename, section, id, "0", title));
part_number += 1;
} else if (std::regex_match(line, match, section_rgx)) {
std::string pre { match[1] };
int level { std::stoi(match[2]) };
std::string attr { match[3] };
std::string title { match[4] };
bool numbered = attr.find("numbered") != std::string::npos;
std::string section {};
if (numbered) {
levels[level-1] = levels[level-1] + 1;
for (unsigned int li = level; li < depth; li++)
levels[li] = 0;
section = levels_to_section(levels);
}
std::string id {};
std::smatch id_match {};
if (std::regex_match(attr, id_match, id_rgx)) {
id = id_match[2];
} else {
if (section.size() == 0) {
id = "_su_" + std::to_string(unnumbered_id++);
} else {
id = "_s_" + string_replace(section, ".", "_");
}
}
std::string link_target = file_basename(basename) + link_delimiter + id;
section_id_map[title] = link_target;
//<h1 id="image-tests" style="clear:both;" class="headerlink">
// <span class="sectionnumber">1</span>Image tests</h1>
elements_t title_parts {};
if (numbered) {
title_parts.push_back(html::elt("span", trim(section)).attr("class", "sectionnumber"));
}
title_parts.push_back(html::elt("span", trim(title)).attr("class", "sectiontitle"));
// msg() << "title_parts: " << title_parts << "\n";
elements_t section_title
{ html::elt("h"+std::to_string(level), title_parts)
.attr("id", id)
.attr("data-level", std::to_string(level))};
// msg() << pre << section_title << "\n";
result << pre << section_title << "\n";
headings.push_back(Heading(level, basename, section, id, "0", title));
} else {
result << line << "\n";
}
}
//std::cout << "add_section_numbers: " << result.str() << " "
//<< headings.size() << " " << levels.size() << " " << id_number << "\n";
return std::tuple(result.str(), headings, levels, id_number);
}
*/
std::string make_html_table_of_contents(std::vector<Heading> headings)
{
elements_t toc { html::elt("h1", "Contents") };