@link :section links to a section by its heading, with an alone value (from dev 831455f36fcd)
A section link names the TEXT of a heading: :section alone means the current file, a value names the file of the same @document that has it. Resolved by @document at assembly in html and tex; errors for a missing file, a missing title, a doubled title and a link outside a @document. The Markdown converter reads a Markdown anchor as a section link to the heading its slug names. Also from dev: the converter's four silent defects fixed and --chapter (9ae2c4cd), and the engine's alone-value error text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
32aa47d41c
commit
f4dcdf3ffd
15 files changed
+594
-114
No files matched your search
@@ -23,7 +23,7 @@ DOCUMENT := document$(DSO)
|
||||
DOC_LDFLAGS := $(SHARED) $(EXPORT_DYNAMIC) -Wl,-rpath,'$(ORIGIN)/../../lib' -L$(LIBDIR)
|
||||
|
||||
# Local object files
|
||||
LOCAL_OBJECTS := document_class.o document_html.o document_latex.o heading.o reference.o
|
||||
LOCAL_OBJECTS := document_class.o document_html.o document_latex.o heading.o reference.o section_link.o
|
||||
LOCAL_DEPFILES := document_class.d document_html.d document_latex.d heading.d reference.d document.d
|
||||
|
||||
# External object files from sks/ (mac/*.o now in the shared library)
|
||||
|
||||
@@ -72,6 +72,9 @@ public:
|
||||
std::map<std::string, std::string> id_to_caption_number();
|
||||
std::map<std::string, std::vector<std::pair<std::string, std::string>>> html_references();
|
||||
void resolve_html_references();
|
||||
void resolve_section_links(); // html, @link :section
|
||||
std::string resolve_tex_section_links(
|
||||
const std::vector<std::pair<std::string, std::string>>& pieces); // (basename, tex)
|
||||
std::string single_page_toc();
|
||||
elements_t page(const std::string& body, const std::string& output_dir, int max_level);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "section_link.h"
|
||||
#include <iomanip>
|
||||
#include "util.h"
|
||||
#include "error.h"
|
||||
@@ -315,6 +316,7 @@ void Document_class::process_input_files()
|
||||
//if (true || !in_modification_order(inpath, cached_filename, m_no_cache)) {
|
||||
if (m_no_cache || cache_requires_update(m_cache_dir, inpath.string(), basename)) {
|
||||
Machine M = m_machine;
|
||||
M.m_state.set("K_document_file", basename); // see document_latex.cpp
|
||||
M.read(inpath);
|
||||
html_text = trim(M.apply("html"));
|
||||
html_text = trim(html::make_paragraphs(html_text)) + "\n";
|
||||
@@ -686,6 +688,54 @@ elements_t Document_class::page(const std::string& body_text, const std::string&
|
||||
return page;
|
||||
}
|
||||
|
||||
// @link :section (section_link.h). Every heading element that carries an
|
||||
// id -- numbered or not, so @h1..@h7 and @part are linkable too, which is
|
||||
// why this reads the elements rather than the headings table that
|
||||
// insert_section_numbers() builds from the numbered ones -- is a target;
|
||||
// the Link klammer's href marker is resolved against them. A single page
|
||||
// gets href="#id". A book's pages are embedded in index.html and shown by
|
||||
// go_to_section(), so a link there is the "pagelink" anchor that
|
||||
// sks/link/js/link.js binds: data-page and data-id, with the app's own
|
||||
// "#page#id" hash as the href so the location survives without the script.
|
||||
// Before 2026-10-12 html had no resolver and the marker was the href.
|
||||
void Document_class::resolve_section_links()
|
||||
{
|
||||
static const std::regex heading_rgx(R"(<(h[1-7]|kt-chapter|kt-part)\b([^>]*)>(.*?)</\1>)");
|
||||
static const std::regex link_rgx(R"(href="__seclink__(.*?)__KTSL__(.*?)__")");
|
||||
std::vector<Section_target> table {};
|
||||
strings_t files {};
|
||||
for (const auto& [basename, text] : m_file_components) {
|
||||
files.push_back(basename);
|
||||
for (std::sregex_iterator it(text.cbegin(), text.cend(), heading_rgx), end; it != end; ++it) {
|
||||
const std::smatch& m = *it;
|
||||
std::string attrs = m[2];
|
||||
std::smatch id_match {};
|
||||
if (std::regex_search(attrs, id_match, id_attr_rgx)) {
|
||||
table.push_back({normalize_section_title(m[3]), basename, id_match[1]});
|
||||
}
|
||||
}
|
||||
}
|
||||
const bool book = m_structure == docstruct_t::book;
|
||||
for (auto& [basename, text] : m_file_components) {
|
||||
std::string result {};
|
||||
auto begin = text.cbegin();
|
||||
for (std::sregex_iterator it(text.cbegin(), text.cend(), link_rgx), end; it != end; ++it) {
|
||||
const std::smatch& m = *it;
|
||||
result.append(begin, m[0].first);
|
||||
const Section_target t = resolve_section_target(table, m[1], m[2], basename, files);
|
||||
if (book) {
|
||||
result += "href=\"#" + t.basename + "#" + t.id + "\" class=\"pagelink\" data-page=\""
|
||||
+ t.basename + "\" data-id=\"" + t.id + "\"";
|
||||
} else {
|
||||
result += "href=\"#" + t.id + "\"";
|
||||
}
|
||||
begin = m[0].second;
|
||||
}
|
||||
result.append(begin, text.cend());
|
||||
text = result;
|
||||
}
|
||||
}
|
||||
|
||||
std::string Document_class::make_single_html_page(const std::string& output_directory)
|
||||
{
|
||||
(void)K::log(3);
|
||||
@@ -787,6 +837,7 @@ std::string Document_class::html()
|
||||
}
|
||||
auto headings = insert_section_numbers();
|
||||
insert_section_numbers("___NUM2___", false);
|
||||
resolve_section_links();
|
||||
insert_html_caption_numbers();
|
||||
resolve_html_references();
|
||||
if (m_structure != docstruct_t::book) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include "section_link.h"
|
||||
#include "util.h"
|
||||
#include "kutil.h"
|
||||
#include "log.h"
|
||||
@@ -20,15 +21,54 @@ std::string Document_class::create_tex_output_directories()
|
||||
return output_directory;
|
||||
}
|
||||
|
||||
// @link :section (section_link.h). Section.tex() announces every heading
|
||||
// in a comment line; the links are resolved against them, every link on a
|
||||
// line. The resolver this replaces (2026-10-12) read ONE link per line --
|
||||
// std::regex_match once per line -- so a second @link ... :section @ on a
|
||||
// line reached the pdf as a dead \hyperlink{__seclink__...}; it ignored
|
||||
// :basename, and it wrote \hyperlink{} for an unknown title.
|
||||
std::string Document_class::resolve_tex_section_links(
|
||||
const std::vector<std::pair<std::string, std::string>>& pieces)
|
||||
{
|
||||
static const std::regex announce_rgx(
|
||||
R"(%__sectionlink__(.*?)__KTSL__(.*?)__KTSL__(.*)__\n?)");
|
||||
static const std::regex link_rgx(R"(__seclink__(.*?)__KTSL__(.*?)__)");
|
||||
std::vector<Section_target> table {};
|
||||
strings_t files {};
|
||||
for (const auto& [basename, text] : pieces) {
|
||||
if (!basename.empty()) files.push_back(basename);
|
||||
for (const std::string& line : find_all(text, announce_rgx, 0)) {
|
||||
std::smatch m {};
|
||||
std::regex_match(line, m, announce_rgx);
|
||||
table.push_back({normalize_section_title(m[3]), m[2], m[1]});
|
||||
}
|
||||
}
|
||||
std::string result {};
|
||||
for (const auto& [basename, text] : pieces) {
|
||||
std::string stripped = std::regex_replace(text, announce_rgx, "");
|
||||
auto begin = stripped.cbegin();
|
||||
for (std::sregex_iterator it(stripped.cbegin(), stripped.cend(), link_rgx), end; it != end; ++it) {
|
||||
const std::smatch& m = *it;
|
||||
result.append(begin, m[0].first);
|
||||
result += resolve_section_target(table, m[1], m[2], basename, files).id;
|
||||
begin = m[0].second;
|
||||
}
|
||||
result.append(begin, stripped.cend());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string Document_class::tex()
|
||||
{
|
||||
(void)K::log(3);
|
||||
create_tex_output_directories();
|
||||
// :text content is already processed (klammers applied by the outer Machine).
|
||||
// Use it directly; don't re-process through a sub-Machine.
|
||||
std::string latex_text {};
|
||||
// The pieces stay apart until the :section links are resolved, so an
|
||||
// error can name the chapter that holds the link; "" is the :text.
|
||||
std::vector<std::pair<std::string, std::string>> pieces {};
|
||||
if (!m_text.empty()) {
|
||||
latex_text = add_latex_caption_numbers(m_text);
|
||||
pieces.push_back({"", add_latex_caption_numbers(m_text)});
|
||||
}
|
||||
if (m_files.size() > 0) {
|
||||
for (auto input_filename : m_files) {
|
||||
@@ -39,38 +79,25 @@ std::string Document_class::tex()
|
||||
if (m_no_cache || cache_requires_update(m_cache_dir, inpath.string(), basename)) {
|
||||
Machine M = m_machine;
|
||||
M.m_state.set("document_landscape", landscape ? "true" : "false"); // , "Landscape mode");
|
||||
// The chapter's basename: Section.tex() builds stable
|
||||
// \hypertarget names from it (a counter alone collided
|
||||
// when the cache served some chapters and not others), and
|
||||
// phases.check_section_links reads its presence as "inside
|
||||
// a @document". Same in the html path.
|
||||
M.m_state.set("K_document_file", basename);
|
||||
M.read(inpath);
|
||||
std::string processed = trim(M.apply("tex", false));
|
||||
processed = add_latex_caption_numbers(processed);
|
||||
write_to_cache(m_cache_dir, basename, processed);
|
||||
latex_text += processed;
|
||||
pieces.push_back({basename, processed});
|
||||
} else {
|
||||
latex_text += read_from_cache(m_cache_dir, basename);
|
||||
pieces.push_back({basename, read_from_cache(m_cache_dir, basename)});
|
||||
}
|
||||
}
|
||||
}
|
||||
std::string latex_text = resolve_tex_section_links(pieces);
|
||||
auto split_lines = regex_split(latex_text, std::regex(R"(\n)"), false);
|
||||
latex_text = resolve_caption_references("latex", split_lines, latex_line_states(split_lines));
|
||||
std::map<std::string, std::string> section_to_id {};
|
||||
std::regex section_id_rgx(R"(%__sectionlink__(.*?)__(.*?)__)");
|
||||
for (std::string id_section : find_all(latex_text, section_id_rgx, 0)) {
|
||||
std::smatch match {};
|
||||
std::regex_match(id_section, match, section_id_rgx);
|
||||
section_to_id[match[2]] = match[1];
|
||||
}
|
||||
latex_text = std::regex_replace(latex_text, section_id_rgx, "");
|
||||
std::regex seclink_rgx("(.*?)__seclink__(.*?)__(.*)");
|
||||
auto lines = regex_split(latex_text, std::regex(R"(\n)"), false);
|
||||
for (auto i = 0ul; i < lines.size(); i++) {
|
||||
if (lines[i].find("__seclink__") != std::string::npos) {
|
||||
std::smatch match {};
|
||||
std::regex_match(lines[i], match, seclink_rgx);
|
||||
std::stringstream ss {};
|
||||
ss << match[1] << section_to_id[match[2]] << match[3];
|
||||
lines[i] = ss.str();
|
||||
}
|
||||
}
|
||||
latex_text = join(lines, "\n");
|
||||
latex_text = string_replace(latex_text, "___AT___", "^@");
|
||||
// @typeface: collect what the document asked for, declare each as a
|
||||
// \newfontfamily switch command in the preamble, and replace every
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#include "section_link.h"
|
||||
#include "error.h"
|
||||
#include "locator.h"
|
||||
#include "util.h"
|
||||
#include <regex>
|
||||
#include <sstream>
|
||||
|
||||
std::string normalize_section_title(const std::string& s)
|
||||
{
|
||||
static const std::regex number_rgx(R"(<span class="sectionnumber">.*?</span>)");
|
||||
static const std::regex tag_rgx("<[^>]*>");
|
||||
static const std::regex tex_command_rgx(R"(\\[A-Za-z@]+\*?)");
|
||||
static const std::regex space_rgx(R"(\s+)");
|
||||
std::string t = std::regex_replace(s, number_rgx, "");
|
||||
t = std::regex_replace(t, tag_rgx, "");
|
||||
t = std::regex_replace(t, tex_command_rgx, "");
|
||||
t = string_replace(string_replace(t, "{", ""), "}", "");
|
||||
t = std::regex_replace(t, space_rgx, " ");
|
||||
return trim(t);
|
||||
}
|
||||
|
||||
Section_target resolve_section_target(
|
||||
const std::vector<Section_target>& table, const std::string& title,
|
||||
const std::string& file, const std::string& where,
|
||||
const std::vector<std::string>& files)
|
||||
{
|
||||
const std::string wanted = normalize_section_title(title);
|
||||
const std::string in = file.empty() ? where : file; // the one file searched
|
||||
const std::string in_name = in.empty() ? std::string("the document's :text") : in;
|
||||
std::stringstream ss {};
|
||||
ss << "In " << (where.empty() ? std::string("the document's :text") : where)
|
||||
<< ", a :section link names the heading " << q_(title);
|
||||
if (!file.empty()) {
|
||||
bool known = false;
|
||||
for (const auto& f : files) known = known || f == file;
|
||||
if (!known) {
|
||||
ss << " in " << file << ", but the document has no file " << file
|
||||
<< " (its files are " << join(files, ", ") << ").";
|
||||
throw Argument_error(ss.str(), Locator::none());
|
||||
}
|
||||
}
|
||||
std::vector<const Section_target*> hits {};
|
||||
for (const auto& t : table) {
|
||||
if (t.basename == in && t.title == wanted) hits.push_back(&t);
|
||||
}
|
||||
if (hits.size() == 1) return *hits[0];
|
||||
if (hits.empty()) {
|
||||
ss << ", which " << in_name << " does not have"
|
||||
<< (file.empty() ? " (a section in another file needs its basename as the value of :section)." : ".");
|
||||
} else {
|
||||
ss << ", which " << in_name << " has " << hits.size()
|
||||
<< " times: give one of them an :id and link to that.";
|
||||
}
|
||||
throw Argument_error(ss.str(), Locator::none());
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// @link :section -- a link to a section by the TEXT of its heading.
|
||||
//
|
||||
// Decided by Andy, 2026-10-12. :section (argtype section_link) written
|
||||
// alone says the heading is in the CURRENT file; written with a value, the
|
||||
// value is the basename (no directory, no extension) of the file of the
|
||||
// same @document that has it. So a link names exactly one file, and a
|
||||
// title shared between files is never ambiguous. Titles match with
|
||||
// klammer markup removed and whitespace collapsed to one space,
|
||||
// case-sensitive.
|
||||
//
|
||||
// Resolution happens here, when @document assembles its files -- the only
|
||||
// place every heading is known -- from two markers the klammers emit:
|
||||
// Link __seclink__<title>__KTSL__<file>__ (in the href / \hyperlink;
|
||||
// <file> empty for the current one)
|
||||
// Section %__sectionlink__<id>__KTSL__<basename>__KTSL__<title>__ (tex only;
|
||||
// the html pass reads the heading elements themselves, which
|
||||
// carry their ids after insert_ids())
|
||||
// KTSL separates the fields because a title or a basename may contain a
|
||||
// single underscore (md_to_sks_test). A file the document does not have, a
|
||||
// title the file does not have, and a title it has more than once are
|
||||
// errors, never fallbacks (the commands do not warn and continue).
|
||||
#ifndef SECTION_LINK_H
|
||||
#define SECTION_LINK_H
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct Section_target {
|
||||
std::string title {}; // normalized
|
||||
std::string basename {}; // the file's basename, "" for :text content
|
||||
std::string id {}; // the anchor: html id, or tex \hypertarget name
|
||||
};
|
||||
|
||||
// The comparison form of a heading's text: tags and TeX control sequences
|
||||
// removed, braces dropped, whitespace collapsed, trimmed.
|
||||
std::string normalize_section_title(const std::string& s);
|
||||
|
||||
// The target a :section link names, or an Argument_error that says why
|
||||
// there is none. FILE is the :section value ("" for the current file),
|
||||
// WHERE the basename of the file holding the link ("" for the document's
|
||||
// :text) and FILES the document's basenames. At assembly the link's own
|
||||
// position is gone, so the file is the location an error can give
|
||||
// (Locator::none(), never the C++ site).
|
||||
Section_target resolve_section_target(
|
||||
const std::vector<Section_target>& table, const std::string& title,
|
||||
const std::string& file, const std::string& where,
|
||||
const std::vector<std::string>& files);
|
||||
|
||||
#endif
|
||||
Reference in new issue
Block a user