From ca70580e6248294d0b940607c369f5f9da9783a6 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Mon, 5 Oct 2026 13:30:00 +0300 Subject: [PATCH] feat(reader): full Markdown and HTML rendering for GitHub READMEs, tables, images and links --- TODO.md | 15 +- app/reader.py | 10 +- app/static/css/style.css | 134 ++++++++++++++++ app/static/index.html | 4 +- app/static/js/app.js | 323 +++++++++++++++++++++++++++++++++------ 5 files changed, 432 insertions(+), 54 deletions(-) diff --git a/TODO.md b/TODO.md index 9936447..ca9c37e 100644 --- a/TODO.md +++ b/TODO.md @@ -21,12 +21,21 @@ - **Слова внутри списков и цитат остаются кликабельными** (интерактивный клик-перевод работает!). - [x] **Заголовки Markdown (`##`, `###`)**: - Конвертация в `

`, `

` с удалением сырых решёток `#`. +- [x] **Таблицы Markdown (`| col | col |`)**: + - Адаптивные таблицы с рамками, стилизацией заголовков ``, зебра-подсветкой строк и горизонтальным скроллом на мобильных. + - Каждая ячейка поддерживает интерактивные слова для изучения и инлайн-код. +- [x] **HTML-блоки и теги в Markdown (`

`, `

`, ``, ``, ``, `
`)**: + - Нативный парсинг через встроенный `DOMParser` без внешних библиотек. + - Устранена утечка сырых тегов (``, ``, `

`) в словарные слова. +- [x] **Ссылки и изображения (`[text](url)`, `![alt](url)`, ``)**: + - Поддержка бейджей shields.io, тренд-графиков и внешних картинок. + - Автоматическое скрытие битых/отсутствующих локальных путей (`assets/...`) через обработчик `onerror`. - [x] **Точное позиционирование окна перевода у курсора/слова (Popup Positioning)**: - Исправлен баг двойного смещения координат: убран `window.scrollY` при `position: fixed`. - - Окно позиционируется точно под кликнутым словом (`rect.bottom + 8`) либо над ним (`rect.top - popupHeight - 8`), если снизу недостаточно места. - - Окно центрируется относительно кликнутого слова с проверкой границ вьюпорта (отступы минимум 12px от краёв). + - Окно перенесено в корень `` для исключения искажений контекста позиционирования. + - Плавное отслеживание скролла через `requestAnimationFrame` с автозакрытием при уходе слова за границы экрана или под липкую шапку. - [x] **Стилизация в CSS ([`app/static/css/style.css`](file:///home/ai-projects/services/study.qpid.pro/app/static/css/style.css))**: - - Добавлены стили для `.reader-content-body pre`, `code`, `blockquote`, `ul`, `ol`, `.reader-code-block`, `.reader-inline-code`. + - Добавлены стили для `.reader-content-body pre`, `code`, `blockquote`, `ul`, `ol`, `.reader-table`, `.reader-heading`, `.reader-link`, `.reader-img`. --- diff --git a/app/reader.py b/app/reader.py index e73106f..ff6930e 100644 --- a/app/reader.py +++ b/app/reader.py @@ -294,8 +294,8 @@ def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple lines = content.split("\n") first_line = lines[0].strip() - # Extract title from first line if default - if title == "Untitled": + # Extract title from first line or # Heading / if default or generic README + if title == "Untitled" or title.lower().startswith("readme"): if first_line.startswith("# "): title = first_line.lstrip("# ").strip() if len(lines) > 1: @@ -304,6 +304,12 @@ def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple title = first_line.split(":", 1)[1].strip() if len(lines) > 1: content = "\n".join(lines[1:]).strip() + else: + h1_match = re.search(r"]*>(.*?)

", content[:1500], re.IGNORECASE) + if h1_match: + detected = re.sub(r"<[^>]+>", "", h1_match.group(1)).strip() + if detected: + title = detected # Extract author from next line if missing if not author: diff --git a/app/static/css/style.css b/app/static/css/style.css index 9788fab..05d6fde 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -2808,6 +2808,140 @@ body { margin: 32px 0; } +/* Headings */ +.reader-content-body h1, +.reader-content-body h2, +.reader-content-body h3, +.reader-content-body h4, +.reader-content-body h5, +.reader-content-body h6 { + color: var(--text-main); + font-weight: 700; + line-height: 1.35; + margin: 32px 0 16px; + text-indent: 0 !important; +} + +.reader-content-body h1 { + font-size: 28px; + border-bottom: 1px solid var(--border-color); + padding-bottom: 10px; +} + +.reader-content-body h2 { + font-size: 23px; + border-bottom: 1px solid var(--border-color); + padding-bottom: 8px; +} + +.reader-content-body h3 { + font-size: 19px; +} + +.reader-content-body h4 { + font-size: 17px; +} + +/* Markdown Tables */ +.reader-table-wrap { + overflow-x: auto; + margin: 24px 0; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + background: var(--bg-surface); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.reader-table { + width: 100%; + border-collapse: collapse; + font-size: 14.5px; + line-height: 1.6; + text-indent: 0 !important; +} + +.reader-table th, +.reader-table td { + padding: 10px 16px; + border-bottom: 1px solid var(--border-color); + border-right: 1px solid var(--border-color); + vertical-align: top; + text-indent: 0 !important; +} + +.reader-table th:last-child, +.reader-table td:last-child { + border-right: none; +} + +.reader-table tr:last-child td { + border-bottom: none; +} + +.reader-table th { + background-color: rgba(255, 255, 255, 0.05); + font-weight: 700; + color: var(--text-main); + text-align: left; +} + +.reader-table tbody tr:hover { + background-color: rgba(255, 255, 255, 0.02); +} + +/* Links & Images */ +.reader-link { + color: var(--primary); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 3px; + transition: color 0.15s ease; +} + +.reader-link:hover { + color: var(--primary-hover); +} + +.reader-img { + max-width: 100%; + height: auto; + border-radius: var(--radius-md); + margin: 8px 0; + vertical-align: middle; +} + +.reader-content-body p img { + margin: 4px 6px; + display: inline-block; + vertical-align: middle; +} + +.reader-content-body p[style*="text-align: center"], +.reader-content-body p.text-center { + text-indent: 0 !important; +} + +.reader-content-body strong, +.reader-content-body b { + font-weight: 700; + color: var(--text-main); +} + +.reader-content-body em, +.reader-content-body i { + font-style: italic; +} + +.reader-content-body sub, +.reader-content-body sup { + font-size: 0.8em; + line-height: 0; + position: relative; + vertical-align: baseline; +} +.reader-content-body sub { bottom: -0.25em; } +.reader-content-body sup { top: -0.5em; } + /* Interactive Words */ .reader-word { cursor: pointer; diff --git a/app/static/index.html b/app/static/index.html index 5201876..0bbd262 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -5,7 +5,7 @@ Notes — Персональный тренажер и словарь английского языка - + @@ -922,6 +922,6 @@ - + diff --git a/app/static/js/app.js b/app/static/js/app.js index f458e91..94bd7e5 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1888,16 +1888,54 @@ document.addEventListener('DOMContentLoaded', () => { codeBlock.appendChild(header); codeBlock.appendChild(pre); readerContentBody.appendChild(codeBlock); + } else if (block.type === 'table') { + const wrap = document.createElement('div'); + wrap.className = 'reader-table-wrap'; + const table = document.createElement('table'); + table.className = 'reader-table'; + + if (block.headers && block.headers.length > 0) { + const thead = document.createElement('thead'); + const headerTr = document.createElement('tr'); + block.headers.forEach((hText, colIdx) => { + const th = document.createElement('th'); + if (block.alignments && block.alignments[colIdx]) { + th.style.textAlign = block.alignments[colIdx]; + } + renderInteractiveSpans(th, hText, hText, knownWordsMap); + headerTr.appendChild(th); + }); + thead.appendChild(headerTr); + table.appendChild(thead); + } + + if (block.rows && block.rows.length > 0) { + const tbody = document.createElement('tbody'); + block.rows.forEach(row => { + const tr = document.createElement('tr'); + row.forEach((cellText, colIdx) => { + const td = document.createElement('td'); + if (block.alignments && block.alignments[colIdx]) { + td.style.textAlign = block.alignments[colIdx]; + } + renderInteractiveSpans(td, cellText, cellText, knownWordsMap); + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + } + wrap.appendChild(table); + readerContentBody.appendChild(wrap); } else if (block.type === 'heading') { - const tag = `h${Math.min(4, Math.max(2, block.level))}`; + const tag = `h${Math.min(4, Math.max(1, block.level || 2))}`; const h = document.createElement(tag); + if (block.align) h.style.textAlign = block.align; renderInteractiveSpans(h, block.text, block.text, knownWordsMap); readerContentBody.appendChild(h); } else if (block.type === 'blockquote') { const bq = document.createElement('blockquote'); - const p = document.createElement('p'); - renderInteractiveSpans(p, block.text, block.text, knownWordsMap); - bq.appendChild(p); + renderInteractiveSpans(bq, block.text, block.text, knownWordsMap); readerContentBody.appendChild(bq); } else if (block.type === 'ul' || block.type === 'ol') { const listEl = document.createElement(block.type); @@ -1912,6 +1950,10 @@ document.addEventListener('DOMContentLoaded', () => { } else { // paragraph const p = document.createElement('p'); + if (block.align) { + p.style.textAlign = block.align; + p.classList.add('text-center'); + } renderInteractiveSpans(p, block.text, block.text, knownWordsMap); readerContentBody.appendChild(p); } @@ -1964,7 +2006,41 @@ document.addEventListener('DOMContentLoaded', () => { continue; } - // 3. Headings: #, ##, ###, ####, #####, ###### + // 3. Markdown Table: lines starting with | and ending with | + if (trimmed.startsWith('|') && trimmed.endsWith('|')) { + const tableLines = []; + while (i < lines.length && lines[i].trim().startsWith('|')) { + tableLines.push(lines[i].trim()); + i++; + } + if (tableLines.length >= 2) { + const splitRow = (rowStr) => { + const inner = rowStr.replace(/^\|/, '').replace(/\|$/, ''); + return inner.split(/(? c.trim().replace(/\\\|/g, '|')); + }; + const headers = splitRow(tableLines[0]); + const alignRow = splitRow(tableLines[1]); + const alignments = alignRow.map(col => { + const tc = col.trim(); + if (tc.startsWith(':') && tc.endsWith(':')) return 'center'; + if (tc.endsWith(':')) return 'right'; + return 'left'; + }); + const rows = []; + for (let r = 2; r < tableLines.length; r++) { + rows.push(splitRow(tableLines[r])); + } + blocks.push({ + type: 'table', + headers: headers, + alignments: alignments, + rows: rows + }); + continue; + } + } + + // 4. Markdown Headings: #, ##, ###, ####, #####, ###### const headingMatch = line.match(/^(\#{1,6})\s+(.+)$/); if (headingMatch) { blocks.push({ @@ -1976,14 +2052,31 @@ document.addEventListener('DOMContentLoaded', () => { continue; } - // 4. Horizontal Rule: --- or *** or ___ - if (/^(\-{3,}|\*{3,}|\_{3,})$/.test(trimmed)) { + // HTML Heading: ... + const htmlHeadingMatch = line.match(/^]*)>(.*?)<\/h\1>/i); + if (htmlHeadingMatch) { + const level = parseInt(htmlHeadingMatch[1], 10); + const attrs = htmlHeadingMatch[2] || ''; + const hText = htmlHeadingMatch[3].trim(); + const align = /align=["']?center["']?/i.test(attrs) ? 'center' : ''; + blocks.push({ + type: 'heading', + level: level, + text: hText, + align: align + }); + i++; + continue; + } + + // 5. Horizontal Rule: --- or *** or ___ or
+ if (/^(\-{3,}|\*{3,}|\_{3,})$/.test(trimmed) || trimmed === '
' || trimmed === '
' || trimmed === '
') { blocks.push({ type: 'hr' }); i++; continue; } - // 5. Blockquote: > ... + // 6. Blockquote: > ... if (trimmed.startsWith('>')) { const quoteLines = []; while (i < lines.length && lines[i].trim().startsWith('>')) { @@ -1997,7 +2090,7 @@ document.addEventListener('DOMContentLoaded', () => { continue; } - // 6. Unordered List: - item or * item or + item + // 7. Unordered List: - item or * item or + item if (/^[-*+]\s+/.test(trimmed)) { const listItems = []; while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) { @@ -2011,7 +2104,7 @@ document.addEventListener('DOMContentLoaded', () => { continue; } - // 7. Ordered List: 1. item + // 8. Ordered List: 1. item if (/^\d+\.\s+/.test(trimmed)) { const listItems = []; while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) { @@ -2025,7 +2118,27 @@ document.addEventListener('DOMContentLoaded', () => { continue; } - // 8. Regular paragraph: gather lines until next block marker or empty line + // 9. Centered HTML block (

or

) + if (/^<(?:p|div)[^>]*align=["']?center["']?[^>]*>/i.test(trimmed)) { + const htmlLines = []; + while (i < lines.length) { + const cur = lines[i]; + htmlLines.push(cur.trim()); + if (/<\/(?:p|div)>/i.test(cur)) { + i++; + break; + } + i++; + } + blocks.push({ + type: 'paragraph', + text: htmlLines.join(' '), + align: 'center' + }); + continue; + } + + // 10. Regular paragraph: gather lines until next block marker or empty line const paraLines = []; while (i < lines.length) { const cur = lines[i]; @@ -2033,10 +2146,13 @@ document.addEventListener('DOMContentLoaded', () => { if (!curTrim) break; if (curTrim.startsWith('```') || /^#{1,6}\s+/.test(curTrim) || + /^]*>/i.test(curTrim) || + (curTrim.startsWith('|') && curTrim.endsWith('|')) || curTrim.startsWith('>') || /^[-*+]\s+/.test(curTrim) || /^\d+\.\s+/.test(curTrim) || - /^(\-{3,}|\*{3,}|\_{3,})$/.test(curTrim)) { + /^(\-{3,}|\*{3,}|\_{3,})$/.test(curTrim) || + /^<(?:p|div)[^>]*align=["']?center/i.test(curTrim)) { break; } paraLines.push(curTrim); @@ -2053,47 +2169,160 @@ document.addEventListener('DOMContentLoaded', () => { return blocks; } - // Render text with inline formatting and interactive clickable word spans + // Render text with full inline Markdown/HTML formatting and interactive clickable word spans function renderInteractiveSpans(container, text, contextText, knownWordsMap) { if (!text) return; - const inlineCodeParts = text.split(/(`[^`]+`)/); - inlineCodeParts.forEach(part => { - if (part.startsWith('`') && part.endsWith('`') && part.length >= 2) { - const codeNode = document.createElement('code'); - codeNode.className = 'reader-inline-code'; - codeNode.textContent = part.slice(1, -1); - container.appendChild(codeNode); - return; + + // 1. Normalize entities & clean non-semantic wrapper tags + let processed = text + .replace(/·/g, '·') + .replace(/•/g, '•') + .replace(/—/g, '—') + .replace(/–/g, '–'); + + // Strip enclosing and

tags if present + processed = processed.replace(/^]*>/i, '').replace(/<\/p>$/i, ''); + // Strip wrapper picture and source tags + processed = processed.replace(/<\/?(?:picture|source)[^>]*>/gi, ''); + + // 2. Convert Markdown inline patterns into HTML + // Images: ![alt](url) -> alt + processed = processed.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); + // Links: [text](url) -> $1 + processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + // Inline Code: `code` -> $1 + processed = processed.replace(/`([^`]+)`/g, '$1'); + // Bold: **text** or __text__ -> text + processed = processed.replace(/\*\*([^*]+)\*\*/g, '$1'); + processed = processed.replace(/__([^_]+)__/g, '$1'); + // Italic: *text* or _text_ -> text + processed = processed.replace(/(?$1
'); + processed = processed.replace(/(?$1'); + + // 3. Parse into DOM tree + const parser = new DOMParser(); + const doc = parser.parseFromString(`${processed}`, 'text/html'); + + // 4. Recursive transformer: keeps formatting elements, makes text nodes interactive words + function transformDOMNode(node) { + if (node.nodeType === Node.TEXT_NODE) { + const raw = node.textContent; + if (!raw) return document.createTextNode(''); + const frag = document.createDocumentFragment(); + const tokens = raw.split(/([A-Za-z0-9'-]+)/); + for (const token of tokens) { + const isWord = /^[A-Za-z0-9'-]+$/.test(token) && token.replace(/[^A-Za-z0-9]/g, '').length >= 1; + if (isWord) { + const span = document.createElement('span'); + span.className = 'reader-word'; + span.dataset.word = token; + span.textContent = token; + + const lower = token.toLowerCase(); + if (knownWordsMap && knownWordsMap.has(lower)) { + const card = knownWordsMap.get(lower); + span.classList.add(card.srs_state === 'mastered' ? 'status-mastered' : 'status-learning'); + } + if (isHighlightEnabled) { + span.classList.add('highlight-enabled'); + } + + span.addEventListener('click', (e) => { + e.stopPropagation(); + handleWordClick(token, contextText || raw, span); + }); + + frag.appendChild(span); + } else { + frag.appendChild(document.createTextNode(token)); + } + } + return frag; } - const tokens = part.split(/([A-Za-z0-9'-]+)/); - tokens.forEach(token => { - const isWord = /^[A-Za-z0-9'-]+$/.test(token) && token.replace(/[^A-Za-z0-9]/g, '').length >= 1; - if (isWord) { - const span = document.createElement('span'); - span.className = 'reader-word'; - span.dataset.word = token; - span.textContent = token; + if (node.nodeType === Node.ELEMENT_NODE) { + const tag = node.tagName.toLowerCase(); - const lower = token.toLowerCase(); - if (knownWordsMap && knownWordsMap.has(lower)) { - const card = knownWordsMap.get(lower); - span.classList.add(card.srs_state === 'mastered' ? 'status-mastered' : 'status-learning'); - } - if (isHighlightEnabled) { - span.classList.add('highlight-enabled'); - } - - span.addEventListener('click', (e) => { - e.stopPropagation(); - handleWordClick(token, contextText || text, span); - }); - - container.appendChild(span); - } else { - container.appendChild(document.createTextNode(token)); + // Code and pre: keep as code badge, DO NOT tokenize words! + if (tag === 'code' || tag === 'pre') { + const codeEl = document.createElement('code'); + codeEl.className = 'reader-inline-code'; + codeEl.textContent = node.textContent; + return codeEl; } - }); + + // Images + if (tag === 'img') { + const img = document.createElement('img'); + img.className = 'reader-img'; + img.src = node.getAttribute('src') || ''; + img.alt = node.getAttribute('alt') || ''; + if (node.getAttribute('width')) img.setAttribute('width', node.getAttribute('width')); + if (node.getAttribute('height')) img.setAttribute('height', node.getAttribute('height')); + // Hide gracefully if broken/local relative asset + img.addEventListener('error', () => { + img.style.display = 'none'; + }); + return img; + } + + // Links + if (tag === 'a') { + const a = document.createElement('a'); + a.className = 'reader-link'; + a.href = node.getAttribute('href') || '#'; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + Array.from(node.childNodes).forEach(child => { + a.appendChild(transformDOMNode(child)); + }); + return a; + } + + if (tag === 'br') { + return document.createElement('br'); + } + + if (tag === 'strong' || tag === 'b') { + const strong = document.createElement('strong'); + Array.from(node.childNodes).forEach(child => { + strong.appendChild(transformDOMNode(child)); + }); + return strong; + } + + if (tag === 'em' || tag === 'i') { + const em = document.createElement('em'); + Array.from(node.childNodes).forEach(child => { + em.appendChild(transformDOMNode(child)); + }); + return em; + } + + if (tag === 'sub' || tag === 'sup') { + const el = document.createElement(tag); + Array.from(node.childNodes).forEach(child => { + el.appendChild(transformDOMNode(child)); + }); + return el; + } + + // Generic containers (span, div, etc.) + const containerEl = document.createElement(tag === 'p' ? 'span' : tag); + if (node.hasAttribute('style')) { + containerEl.setAttribute('style', node.getAttribute('style')); + } + Array.from(node.childNodes).forEach(child => { + containerEl.appendChild(transformDOMNode(child)); + }); + return containerEl; + } + + return document.createTextNode(''); + } + + Array.from(doc.body.childNodes).forEach(childNode => { + container.appendChild(transformDOMNode(childNode)); }); }