]*?)/?>#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('#]*name\s*=\s*["\']cover["\'][^>]*content\s*=\s*["\']([^"\']+)["\']#is', $opf, $m)) {
$id = trim($m[1]);
$href = (string)($manifest[$id]['href'] ?? '');
}
if ($href === '') {
if (preg_match('#]*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 会混进正文);
* ② 块级标签换成换行(
),否则整章粘成一团;
* ③ 实体还原( & …)之后再 strip_tags;
* ④ 连续空行压成一个 —— 电子书的源文件里空行多到能占一半篇幅。
*/
private static function xhtmlToText(string $html): array
{
$title = '';
if (preg_match('#]*>(.*?)#is', $html, $m)) {
$title = trim(html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
$body = $html;
if (preg_match('#]*>(.*)#is', $body, $m2)) {
$body = $m2[1];
if ($title === '' && preg_match('#]*>(.*?)#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('#
#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 */
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);
}
}