v24 第 15 批:活动 / 旅程两级语义(术语 · 派生时间城市 · 表单三项 · 题图可点)
用户原话四条,逐条落地:
1. 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
→ 顶层一律改称**活动**:按钮「记一次活动」、页标题「活动」、`N 次` / `N 段旅程`;
活动详情三组改叫「计划的旅程 / 走过的旅程 / 没成行的」;
节点表单「这段旅程叫什么」「阶段」;flash 文案 17 处同步。
(导航条仍叫「旅程」——用户就是这么称呼这个模块,列表页加副标题说清两级关系。)
2. 「活动不需要开始和结束时间,是根据活动中的旅程,最早的旅程开始时间,和最晚的
旅程结束时间,计算出来的。目的地也不需要…去的城市,也是根据旅程,得到的。」
→ 活动**不再存**这三样,全部**读时算出来**:
· Trip::derived() 最早开始 / 最晚结束 / 城市去重
· Trip::cities() 按最早出现时间排(先后顺序本身是信息,不是字母序)
· Trip::attachDerived() 列表页批量补(避免 N+1)
· Trip::activitySpan() 没有旅程时说「还没有旅程」,不用「日期待补」
create/update 不再写 events 的起止与地点(老值保留不动,页面不读)。
列表排序/搜索、首页「最近去了」改走子查询。
3. 「题图那里,没有办法上传图片,点击哪里都没反应。」——**真 bug**
「点图 = 开文件框」那句 JS 只写在 people/form.php 的内页脚本里(头像专用);
第 14 批加的旅程题图与图书封面照抄了同一套 DOM 却没有对应的 JS,
于是图块有 cursor:pointer 与 hover 高亮(看着能点),点下去毫无反应。
→ 做成声明式通用绑定:图上写 data-pick-input="#fileInputId",
app.js 统一接管(一个页面多个也各绑各的),并删掉旧的那份专用实现。
三处(头像 / 题图 / 图书封面)全挂上。
4. 「添加活动的页面,顺序是这样的:添加标题,上传并裁剪题图,然后一段简介,就可以了。」
→ 主区就这三项,顺序照原话;时间/城市不做输入项(只留一句说明它们从哪来,
不说清用户会满页找"怎么没地方填时间")。
备注 / 公开 / 同行的人收进「更多设置」折叠区——功能一个没丢。
顺带修掉两处会炸的实现问题:
· ORDER BY 引用聚合别名(first_at)在 MariaDB/MySQL 报 1247,且只在运行时炸、
php -l 看不出 → 改成 ORDER BY MIN(starts_at),否则整个旅程模块 Fatal。
· e2e 里"详情页显示了派生时间"原本写成整页查找,下面每段旅程自己也渲染日期,
于是把派生值塞成 null 也照样绿(借光假绿)→ 判据收进页头那一段。
验证:e2e 845/0(+15 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 移动几何 688/0 ·
交互 92/0 · 题图真机点击 4/0 · 读书窄屏 15/0 · 家谱 34/0 + 10/0 + 反向 3/3 ·
观影按钮 60/0 + 反向 3/3 · 本批反向验证 4/4(题图绑定抽掉→红、派生值不挂→红、
城市退回读 location→红、时间字段加回表单→红)· 部署静态断言新增两组(含"活动不许
再写起止时间/目的地"与"ORDER BY 不许引用聚合别名"的反退化防线)。
部署后服务器自检 19/19,五个关键文件 md5 与线上逐一核对一致。
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
<?php
|
||||
/**
|
||||
* 图书解析(v24 第 14 批 用户第 5 条):上传一本书 → 读出元数据与正文。
|
||||
*
|
||||
* 用户原话:「可以上传图书,可以解析各种格式的图书」。
|
||||
*
|
||||
* 能力边界(**页面上必须如实写出来**,别让人以为传什么都能读):
|
||||
* · **EPUB** —— 完整解析:元数据(书名/作者/出版社/语言/ISBN)+ 封面 + 按 spine 顺序
|
||||
* 拆出每一章的正文。这是电子书的事实标准,优先做好。
|
||||
* · **TXT** —— 解析:自动认编码(UTF-8 / GBK / BIG5),按"第X章"切章;
|
||||
* 切不出来就按 5000 字一段(否则一本 50 万字的书会变成一整页)。
|
||||
* · **PDF** —— **不提取正文**(服务端没有 PDF 解析扩展,硬写会错得很自信)。
|
||||
* 文件照存,阅读页用浏览器内嵌的 PDF 阅读器看(每家浏览器都有)。
|
||||
* · **MOBI / AZW3 / 其它** —— 不解析正文(亚马逊的私有格式,解析要一本书的成本)。
|
||||
* 文件照存、元数据照手填,仍然能进书架、能记笔记、能统计。
|
||||
*
|
||||
* ⚠️ 解析**只做纯文本**:不保留排版、图片、脚注。
|
||||
* 这是"能读"与"原样还原"之间的取舍 —— 家庭书架要的是"随时翻两页",
|
||||
* 不是排版引擎(那是一个独立项目的量)。
|
||||
*
|
||||
* @return array{ok:bool,error?:string,meta:array,chapters:array,cover?:array}
|
||||
* cover = ['tmp'=>临时文件路径, 'mime'=>…](有封面才有;调用方负责上传与删临时文件)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class BookParser
|
||||
{
|
||||
/** 单章上限(字符):超过就截断,防止一本巨书把库撑爆 */
|
||||
private const CHAPTER_MAX = 200000;
|
||||
/** 一本书最多解析多少章 */
|
||||
private const CHAPTER_LIMIT = 600;
|
||||
|
||||
/**
|
||||
* @param string $path 临时文件路径
|
||||
* @param string $ext 扩展名(小写)
|
||||
* @param string $name 原始文件名(用来兜底猜书名/作者)
|
||||
*/
|
||||
public static function parse(string $path, string $ext, string $name = ''): array
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return ['ok' => false, 'error' => '文件读不到', 'meta' => [], 'chapters' => []];
|
||||
}
|
||||
$ext = strtolower($ext);
|
||||
if ($ext === '' && $name !== '') {
|
||||
$ext = strtolower((string)pathinfo($name, PATHINFO_EXTENSION));
|
||||
}
|
||||
switch ($ext) {
|
||||
case 'epub':
|
||||
return self::parseEpub($path);
|
||||
case 'txt':
|
||||
case 'text':
|
||||
return self::parseTxt($path, $name);
|
||||
case 'pdf':
|
||||
return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name),
|
||||
'chapters' => [], 'note' => 'PDF 只存文件(服务端不提取正文),阅读时用浏览器自带的 PDF 阅读器看。'];
|
||||
default:
|
||||
return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name),
|
||||
'chapters' => [], 'note' => self::formatWord($ext) . ' 正文解析不了,文件已经存好了,元数据请手填。'];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- EPUB ----------------
|
||||
|
||||
/**
|
||||
* EPUB = 一个 zip 包:META-INF/container.xml 指路 → .opf 里放元数据与清单 →
|
||||
* spine 决定章节顺序 → 每一章是一个 XHTML 文件。
|
||||
*
|
||||
* ⚠️ 路径要**相对 opf 所在目录**拼(很多书的 OEBPS/ 与 opf 不在一起),
|
||||
* 直接用 opf 的路径当基准去拼会 404(ZipArchive::getFromName 返回 false)。
|
||||
*/
|
||||
private static function parseEpub(string $path): array
|
||||
{
|
||||
if (!class_exists('ZipArchive')) {
|
||||
return ['ok' => true, 'error' => '', 'meta' => [], 'chapters' => [],
|
||||
'note' => '服务器没有 zip 扩展,EPUB 的正文解析不了;文件已保存,元数据请手填。'];
|
||||
}
|
||||
$zip = new \ZipArchive();
|
||||
$rc = $zip->open($path);
|
||||
if ($rc !== true) {
|
||||
return ['ok' => false, 'error' => '这个 EPUB 打不开(错误码 ' . (int)$rc . ')',
|
||||
'meta' => [], 'chapters' => []];
|
||||
}
|
||||
|
||||
// ① 找 OPF(书的"目录清单")
|
||||
$opfPath = '';
|
||||
$container = $zip->getFromName('META-INF/container.xml');
|
||||
if ($container !== false && $container !== '') {
|
||||
if (preg_match('/full-path\s*=\s*["\']([^"\']+)["\']/i', $container, $m)) {
|
||||
$opfPath = trim($m[1]);
|
||||
}
|
||||
}
|
||||
if ($opfPath === '') {
|
||||
// 有些书没写 container.xml(不合规范但确实存在)→ 自己找第一个 .opf
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$n = (string)$zip->getNameIndex($i);
|
||||
if (substr(strtolower($n), -4) === '.opf') {
|
||||
$opfPath = $n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($opfPath === '') {
|
||||
$zip->close();
|
||||
return ['ok' => false, 'error' => '这个 EPUB 里找不到目录文件(.opf),可能已损坏',
|
||||
'meta' => [], 'chapters' => []];
|
||||
}
|
||||
$opfDir = self::dirOf($opfPath);
|
||||
$opf = (string)$zip->getFromName($opfPath);
|
||||
$zip->close();
|
||||
if ($opf === '') {
|
||||
return ['ok' => false, 'error' => '这个 EPUB 的目录文件是空的', 'meta' => [], 'chapters' => []];
|
||||
}
|
||||
|
||||
$meta = self::opfMeta($opf);
|
||||
// ② 清单:id → 文件
|
||||
$manifest = self::opfManifest($opf);
|
||||
// ③ 顺序:spine 里的 idref
|
||||
$spine = self::opfSpine($opf, $manifest);
|
||||
|
||||
// ④ 封面:metadata 里的 <meta name="cover" content="id">,或 manifest 里 properties 带 cover-image
|
||||
$cover = self::epubCover($path, $opf, $opfDir, $manifest);
|
||||
|
||||
$zip2 = new \ZipArchive();
|
||||
if ($zip2->open($path) !== true) {
|
||||
return ['ok' => true, 'meta' => $meta, 'chapters' => [], 'cover' => $cover,
|
||||
'note' => '元数据读到了,正文没读出来(文件打不开第二次)。'];
|
||||
}
|
||||
$chapters = [];
|
||||
$seq = 0;
|
||||
foreach ($spine as $href) {
|
||||
if (count($chapters) >= self::CHAPTER_LIMIT) {
|
||||
break;
|
||||
}
|
||||
$full = self::joinPath($opfDir, $href);
|
||||
$raw = $zip2->getFromName($full);
|
||||
// ⚠️ 有些书的 spine 里写了 HTML 实体或大小写不一致 → 退一步按名字再试
|
||||
if ($raw === false) {
|
||||
$raw = $zip2->getFromName(ltrim($href, '/'));
|
||||
}
|
||||
if ($raw === false || $raw === '') {
|
||||
continue;
|
||||
}
|
||||
[$title, $body] = self::xhtmlToText($raw);
|
||||
if (trim($body) === '') {
|
||||
continue;
|
||||
}
|
||||
$seq++;
|
||||
$chapters[] = [
|
||||
'seq' => $seq,
|
||||
'title' => $title !== '' ? $title : ('第 ' . $seq . ' 章'),
|
||||
'content' => mb_substr($body, 0, self::CHAPTER_MAX, 'UTF-8'),
|
||||
];
|
||||
}
|
||||
$zip2->close();
|
||||
|
||||
return ['ok' => true, 'error' => '', 'meta' => $meta, 'chapters' => $chapters, 'cover' => $cover];
|
||||
}
|
||||
|
||||
/** OPF 的 metadata:书名 / 作者 / 出版社 / 语言 / ISBN / 简介 */
|
||||
private static function opfMeta(string $opf): array
|
||||
{
|
||||
$get = static function (string $tag) use ($opf): string {
|
||||
if (preg_match_all('#<dc:' . $tag . '[^>]*>(.*?)</dc:' . $tag . '>#is', $opf, $m)) {
|
||||
foreach ($m[1] as $v) {
|
||||
$v = trim(html_entity_decode(strip_tags($v), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
if ($v !== '') {
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
$isbn = '';
|
||||
if (preg_match_all('#<dc:identifier[^>]*>(.*?)</dc:identifier>#is', $opf, $mm)) {
|
||||
foreach ($mm[0] as $i => $full) {
|
||||
$v = trim(strip_tags($mm[1][$i]));
|
||||
// opf:scheme="ISBN" 或 urn:isbn:xxxx
|
||||
if (stripos($full, 'ISBN') !== false || stripos($v, 'isbn') === 0) {
|
||||
$isbn = preg_replace('/^urn:isbn:/i', '', $v) ?? '';
|
||||
break;
|
||||
}
|
||||
if ($isbn === '' && preg_match('/^(97[89])?\d{9}[\dXx]$/', $v)) {
|
||||
$isbn = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
$date = $get('date');
|
||||
return [
|
||||
'title' => $get('title'),
|
||||
'subtitle' => '',
|
||||
'author' => $get('creator'),
|
||||
'publisher' => $get('publisher'),
|
||||
'language' => $get('language'),
|
||||
'isbn' => $isbn,
|
||||
'published' => $date !== '' ? substr($date, 0, 10) : '',
|
||||
'summary' => $get('description'),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,array{href:string,type:string,props:string}> id => 项 */
|
||||
private static function opfManifest(string $opf): array
|
||||
{
|
||||
$out = [];
|
||||
if (!preg_match_all('#<item\s+([^>]*?)/?>#is', $opf, $m)) {
|
||||
return $out;
|
||||
}
|
||||
foreach ($m[1] as $attr) {
|
||||
$a = self::attrs($attr);
|
||||
$id = (string)($a['id'] ?? '');
|
||||
if ($id === '') {
|
||||
continue;
|
||||
}
|
||||
$out[$id] = [
|
||||
'href' => html_entity_decode((string)($a['href'] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'type' => strtolower((string)($a['media-type'] ?? '')),
|
||||
'props' => strtolower((string)($a['properties'] ?? '')),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return string[] 按阅读顺序排的正文文件(相对 opf 目录) */
|
||||
private static function opfSpine(string $opf, array $manifest): array
|
||||
{
|
||||
$ids = [];
|
||||
if (preg_match('#<spine\b[^>]*>(.*?)</spine>#is', $opf, $m)) {
|
||||
if (preg_match_all('#<itemref\s+([^>]*?)/?>#is', $m[1], $mm)) {
|
||||
foreach ($mm[1] as $attr) {
|
||||
$a = self::attrs($attr);
|
||||
$id = (string)($a['idref'] ?? '');
|
||||
if ($id !== '') {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$out = [];
|
||||
foreach ($ids as $id) {
|
||||
$it = $manifest[$id] ?? null;
|
||||
if (!$it) {
|
||||
continue;
|
||||
}
|
||||
$t = (string)$it['type'];
|
||||
// 只收正文档(xhtml / html / xml);图片、css、ncx 跳过
|
||||
if ($t !== '' && strpos($t, 'html') === false && strpos($t, 'xml') === false) {
|
||||
continue;
|
||||
}
|
||||
$out[] = (string)$it['href'];
|
||||
}
|
||||
// spine 为空(不合规范)→ 退一步:按 manifest 里所有 html 收
|
||||
if (!$out) {
|
||||
foreach ($manifest as $it) {
|
||||
$t = (string)$it['type'];
|
||||
if (strpos($t, 'html') !== false) {
|
||||
$out[] = (string)$it['href'];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 封面:找出图并存到临时文件(调用方再交给 Upload) */
|
||||
private static function epubCover(string $path, string $opf, string $opfDir, array $manifest): ?array
|
||||
{
|
||||
$href = '';
|
||||
if (preg_match('#<meta\s+[^>]*name\s*=\s*["\']cover["\'][^>]*content\s*=\s*["\']([^"\']+)["\']#is', $opf, $m)) {
|
||||
$id = trim($m[1]);
|
||||
$href = (string)($manifest[$id]['href'] ?? '');
|
||||
}
|
||||
if ($href === '') {
|
||||
if (preg_match('#<meta\s+[^>]*content\s*=\s*["\']([^"\']+)["\'][^>]*name\s*=\s*["\']cover["\']#is', $opf, $m2)) {
|
||||
$id = trim($m2[1]);
|
||||
$href = (string)($manifest[$id]['href'] ?? '');
|
||||
}
|
||||
}
|
||||
if ($href === '') {
|
||||
foreach ($manifest as $it) {
|
||||
if (strpos((string)$it['props'], 'cover-image') !== false) {
|
||||
$href = (string)$it['href'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($href === '') {
|
||||
foreach ($manifest as $it) {
|
||||
if (strpos((string)$it['type'], 'image/') === 0
|
||||
&& stripos((string)$it['href'], 'cover') !== false) {
|
||||
$href = (string)$it['href'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($href === '') {
|
||||
return null;
|
||||
}
|
||||
if (!class_exists('ZipArchive')) {
|
||||
return null;
|
||||
}
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($path) !== true) {
|
||||
return null;
|
||||
}
|
||||
$full = self::joinPath($opfDir, $href);
|
||||
$bin = $zip->getFromName($full);
|
||||
if ($bin === false) {
|
||||
$bin = $zip->getFromName(ltrim($href, '/'));
|
||||
}
|
||||
$zip->close();
|
||||
if ($bin === false || $bin === '') {
|
||||
return null;
|
||||
}
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'fbcov');
|
||||
if ($tmp === false || @file_put_contents($tmp, $bin) === false) {
|
||||
return null;
|
||||
}
|
||||
$fino = new \finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = (string)$fino->file($tmp);
|
||||
if (strpos($mime, 'image/') !== 0) {
|
||||
@unlink($tmp);
|
||||
return null;
|
||||
}
|
||||
return ['tmp' => $tmp, 'mime' => $mime];
|
||||
}
|
||||
|
||||
/**
|
||||
* XHTML → 纯文本(保留段落换行)。
|
||||
*
|
||||
* ⚠️ 不是"去标签"就完事:
|
||||
* ① 先砍掉 script / style / head(不然 CSS 与 JS 会混进正文);
|
||||
* ② 块级标签换成换行(<p><div><br><h*>),否则整章粘成一团;
|
||||
* ③ 实体还原( & …)之后再 strip_tags;
|
||||
* ④ 连续空行压成一个 —— 电子书的源文件里空行多到能占一半篇幅。
|
||||
*/
|
||||
private static function xhtmlToText(string $html): array
|
||||
{
|
||||
$title = '';
|
||||
if (preg_match('#<title[^>]*>(.*?)</title>#is', $html, $m)) {
|
||||
$title = trim(html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
}
|
||||
$body = $html;
|
||||
if (preg_match('#<body\b[^>]*>(.*)</body>#is', $body, $m2)) {
|
||||
$body = $m2[1];
|
||||
if ($title === '' && preg_match('#<h[1-4][^>]*>(.*?)</h[1-4]>#is', $body, $m3)) {
|
||||
$title = trim(html_entity_decode(strip_tags($m3[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
}
|
||||
}
|
||||
$body = preg_replace('#<(script|style|noscript)\b[^>]*>.*?</\1>#is', '', $body) ?? $body;
|
||||
$body = preg_replace('#<br\s*/?>#i', "\n", $body) ?? $body;
|
||||
$body = preg_replace('#</(p|div|h[1-6]|li|tr|blockquote)>#i', "\n", $body) ?? $body;
|
||||
$body = html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$body = str_replace(["\r\n", "\r", "\xC2\xA0"], ["\n", "\n", ' '], $body);
|
||||
$body = preg_replace('/[ \t]+\n/', "\n", $body) ?? $body;
|
||||
$body = preg_replace('/\n{3,}/', "\n\n", $body) ?? $body;
|
||||
$body = preg_replace('/^\n+|\n+$/', '', $body) ?? $body;
|
||||
return [mb_substr($title, 0, 300, 'UTF-8'), $body];
|
||||
}
|
||||
|
||||
// ---------------- TXT ----------------
|
||||
|
||||
private static function parseTxt(string $path, string $name): array
|
||||
{
|
||||
$raw = (string)@file_get_contents($path);
|
||||
if ($raw === '') {
|
||||
return ['ok' => false, 'error' => '这个文本文件是空的', 'meta' => [], 'chapters' => []];
|
||||
}
|
||||
$text = self::toUtf8($raw);
|
||||
$chapters = self::splitChapters($text);
|
||||
return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name), 'chapters' => $chapters];
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码兜底:UTF-8 → GB18030(中文电子书最常见的老编码)→ BIG5 → Latin1。
|
||||
* ⚠️ 不认编码就硬读,中文会整页乱码 —— 那是"看起来解析了、其实全是问号"。
|
||||
*/
|
||||
private static function toUtf8(string $raw): string
|
||||
{
|
||||
if (substr($raw, 0, 3) === "\xEF\xBB\xBF") {
|
||||
return substr($raw, 3);
|
||||
}
|
||||
if (mb_check_encoding($raw, 'UTF-8')) {
|
||||
return $raw;
|
||||
}
|
||||
foreach (['GB18030', 'BIG-5', 'ISO-8859-1'] as $enc) {
|
||||
$s = @iconv($enc, 'UTF-8//IGNORE', $raw);
|
||||
if ($s !== false && $s !== '') {
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切章:认"第X章/第X节/序/前言/后记/Chapter N"这类行;
|
||||
* 认不出来就按 5000 字一段(不然一本长篇会变成一整页,浏览器直接卡死)。
|
||||
*/
|
||||
private static function splitChapters(string $text): array
|
||||
{
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
$lines = explode("\n", $text);
|
||||
$re = '/^\s*(第\s*[0-9零一二三四五六七八九十百千万]+\s*[章节回部篇卷集]\s*'
|
||||
. '|chapter\s+[0-9ivx]+\b'
|
||||
. '|序章|序言|前言|自序|后记|附录|尾声|楔子)/iu';
|
||||
$chapters = [];
|
||||
$curTitle = '';
|
||||
$buf = '';
|
||||
$flush = static function () use (&$chapters, &$curTitle, &$buf): void {
|
||||
$body = trim($buf);
|
||||
if ($body !== '') {
|
||||
$chapters[] = ['title' => $curTitle, 'content' => mb_substr($body, 0, self::CHAPTER_MAX, 'UTF-8')];
|
||||
}
|
||||
$buf = '';
|
||||
};
|
||||
foreach ($lines as $ln) {
|
||||
$t = trim($ln);
|
||||
if ($t !== '' && mb_strlen($t, 'UTF-8') <= 60 && preg_match($re, $t)) {
|
||||
$flush();
|
||||
$curTitle = mb_substr($t, 0, 200, 'UTF-8');
|
||||
continue;
|
||||
}
|
||||
$buf .= $ln . "\n";
|
||||
}
|
||||
$flush();
|
||||
|
||||
if (count($chapters) <= 1) {
|
||||
// 没切出来 → 按字数均分
|
||||
$chapters = [];
|
||||
$n = mb_strlen($text, 'UTF-8');
|
||||
$size = 5000;
|
||||
$parts = (int)max(1, ceil($n / $size));
|
||||
for ($i = 0; $i < $parts; $i++) {
|
||||
$seg = mb_substr($text, $i * $size, $size, 'UTF-8');
|
||||
if (trim($seg) === '') {
|
||||
continue;
|
||||
}
|
||||
$chapters[] = ['title' => '第 ' . ($i + 1) . ' 段', 'content' => $seg];
|
||||
}
|
||||
}
|
||||
$out = [];
|
||||
$seq = 0;
|
||||
foreach ($chapters as $c) {
|
||||
if (count($out) >= self::CHAPTER_LIMIT) {
|
||||
break;
|
||||
}
|
||||
$seq++;
|
||||
$out[] = ['seq' => $seq, 'title' => (string)$c['title'], 'content' => (string)$c['content']];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ---------------- 文件名猜书名 / 作者 ----------------
|
||||
|
||||
/**
|
||||
* 从 "三体(全三册)- 刘慈欣.epub" 这类文件名里猜。
|
||||
* ⚠️ 只是**兜底**:猜错了用户一眼能改,比空着强(空着要一个字一个字敲)。
|
||||
*/
|
||||
private static function guessFromName(string $name): array
|
||||
{
|
||||
$base = pathinfo((string)$name, PATHINFO_FILENAME);
|
||||
$base = trim(preg_replace('/[\[\((【].*?[\]\))】]/u', '', $base) ?? $base);
|
||||
$author = '';
|
||||
$title = $base;
|
||||
foreach ([' - ', '—', '-', '_'] as $sep) {
|
||||
if (strpos($base, $sep) !== false) {
|
||||
$parts = explode($sep, $base);
|
||||
$title = trim((string)array_pop($parts));
|
||||
$author = trim(implode(' ', $parts));
|
||||
break;
|
||||
}
|
||||
}
|
||||
// "作者 书名" 反过来也常见:名字很短的那一段更像作者
|
||||
if ($author === '' && preg_match('/^(.+?)\s{1}(.+)$/u', $base, $m)) {
|
||||
$author = trim($m[1]);
|
||||
$title = trim($m[2]);
|
||||
}
|
||||
$title = trim(preg_replace('/\.(epub|txt|pdf|mobi|azw3?)$/i', '', $title) ?? $title);
|
||||
return [
|
||||
'title' => mb_substr($title, 0, 300, 'UTF-8'),
|
||||
'subtitle' => '',
|
||||
'author' => mb_substr($author, 0, 200, 'UTF-8'),
|
||||
'publisher' => '',
|
||||
'language' => '',
|
||||
'isbn' => '',
|
||||
'published' => '',
|
||||
'summary' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public static function formatWord(string $ext): string
|
||||
{
|
||||
$map = ['epub' => 'EPUB', 'txt' => 'TXT', 'pdf' => 'PDF', 'mobi' => 'MOBI',
|
||||
'azw' => 'AZW', 'azw3' => 'AZW3', 'doc' => 'DOC', 'docx' => 'DOCX'];
|
||||
return $map[strtolower($ext)] ?? strtoupper($ext);
|
||||
}
|
||||
|
||||
/** 这个文件能不能直接放进浏览器内嵌阅读(PDF 就是) */
|
||||
public static function inlinable(?string $fmt): bool
|
||||
{
|
||||
return strtolower((string)$fmt) === 'pdf';
|
||||
}
|
||||
|
||||
// ---------------- 小工具 ----------------
|
||||
|
||||
/** @return array<string,string> */
|
||||
private static function attrs(string $s): array
|
||||
{
|
||||
$out = [];
|
||||
if (preg_match_all('/([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(["\'])(.*?)\2/s', $s, $m, PREG_SET_ORDER)) {
|
||||
foreach ($m as $x) {
|
||||
$out[strtolower($x[1])] = $x[3];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function dirOf(string $p): string
|
||||
{
|
||||
$p = str_replace('\\', '/', $p);
|
||||
$i = strrpos($p, '/');
|
||||
return $i === false ? '' : substr($p, 0, $i + 1);
|
||||
}
|
||||
|
||||
/** 把 opf 目录与 href 拼成 zip 内的完整路径(顺手去掉 ../ 与锚点) */
|
||||
private static function joinPath(string $dir, string $href): string
|
||||
{
|
||||
$href = preg_replace('/#.*$/', '', $href) ?? $href;
|
||||
$href = str_replace('\\', '/', trim(urldecode($href)));
|
||||
$full = $dir . $href;
|
||||
// 消掉 a/b/../c
|
||||
$parts = [];
|
||||
foreach (explode('/', str_replace('\\', '/', $full)) as $seg) {
|
||||
if ($seg === '' || $seg === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($seg === '..') {
|
||||
array_pop($parts);
|
||||
continue;
|
||||
}
|
||||
$parts[] = $seg;
|
||||
}
|
||||
return implode('/', $parts);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user