# paperdoorknob: Print glowfic # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the # Free Software Foundation, version 3. from abc import ABC, abstractmethod from dataclasses import dataclass import itertools from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse from typing import Iterable from bs4 import BeautifulSoup from bs4.element import Tag from images import ImageStore from texify import Texifier def _removeViewFromURL(url: str) -> str: u = urlparse(url) old_qs = parse_qsl(u.query) new_qs = [(k, v) for k, v in old_qs if k != 'view'] return urlunparse(u._replace(query=urlencode(new_qs))) def nonFlatURL(url: str) -> str: return _removeViewFromURL(url) def flatURL(url: str) -> str: u = urlparse(_removeViewFromURL(url)) qs = parse_qsl(u.query) + [('view', 'flat')] return urlunparse(u._replace(query=urlencode(qs))) @dataclass(frozen=True) class Chunk: icon: str | None character: Tag | None screen_name: Tag | None author: Tag | None content: Tag # We avoid the name "post" because the Glowfic community uses the term # inconsistently: # * The Glowfic software sometimes uses "post" to refer to a whole thread # (in the URL), sometimes uses "post" to refer to chunks (in the CSS), # but mostly uses "post" to refer to just the first chunk in a thread # (in the HTML and UI). The non-first chunks are "replies". # * Readers and this software don't need to distinguish first-chunks and # non-first-chunks. # * Humans in the community tend to use "posts" to mean chunks. def chunkDOMs(html: BeautifulSoup) -> Iterable[Tag]: def text() -> Tag: body = html.body assert body text = body.find_next("div", class_="post-post") assert isinstance(text, Tag) return text def the_replies() -> Iterable[Tag]: rs = html.find_all("div", class_="post-reply") assert all(isinstance(r, Tag) for r in rs) return rs return itertools.chain([text()], the_replies()) def makeChunk(chunk_dom: Tag, image_store: ImageStore) -> Chunk: def getIcon() -> str | None: icon_div = chunk_dom.findChild('div', class_='post-icon') if icon_div is None: return None assert isinstance(icon_div, Tag) icon_img = icon_div.findChild('img') if icon_img is None: return None assert isinstance(icon_img, Tag) return image_store.get_image(icon_img.attrs['src']) def getByClass(css_class: str) -> Tag | None: tag = chunk_dom.findChild('div', class_=css_class) assert tag is None or isinstance(tag, Tag) return tag content = chunk_dom.findChild('div', class_='post-content') assert isinstance(content, Tag) return Chunk(getIcon(), getByClass('post-character'), getByClass('post-screenname'), getByClass('post-author'), content) def renderIcon(icon_path: str | None, image_size: float) -> bytes | None: if icon_path is None: return None return b'\\includegraphics[width=%fmm,height=%fmm,keepaspectratio]{%s}' % ( image_size, image_size, icon_path.encode('UTF-8')) class Layout(ABC): @abstractmethod def renderChunk(self, chunk: Chunk) -> bytes: raise NotImplementedError() class ContentOnlyLayout(Layout): def __init__(self, texifier: Texifier) -> None: self._texifier = texifier def renderChunk(self, chunk: Chunk) -> bytes: return self._texifier.texify(chunk.content) + b'\n' class BelowIconLayout(Layout): def __init__(self, texifier: Texifier, image_size: float) -> None: self._texifier = texifier self._image_size = image_size def renderChunk(self, chunk: Chunk) -> bytes: icon = renderIcon(chunk.icon, self._image_size) meta = [icon] if icon else [] meta += [self._texifier.texify(x) for x in [chunk.character, chunk.screen_name, chunk.author] if x is not None] return b'''\\wrapstuffclear \\begin{wrapstuff}[l] \\fbox{ \\begin{varwidth}{0.5\\textwidth} \\smash{\\parbox[t][0pt]{0pt}{ \\setlength{\\fboxrule}{0.2pt} \\setlength{\\fboxsep}{0pt} \\vspace{-3.4pt} \\fbox{\\hspace{107mm}} }\\\\*} \\vspace{-1em} \\begin{center} %s \\end{center} \\end{varwidth} } \\end{wrapstuff} \\strut \\noindent %s ''' % ( b'\\\\*'.join(meta), self._texifier.texify(chunk.content)) class BesideIconLayout(Layout): def __init__(self, texifier: Texifier, image_size: float) -> None: self._texifier = texifier self._image_size = image_size def renderChunk(self, chunk: Chunk) -> bytes: icon = renderIcon(chunk.icon, self._image_size) meta = [ chunk.character, chunk.screen_name, chunk.author, ] # Why is \textwidth not the width of the text? # Why is the width of the text .765\textwidth? return b'''\\noindent\\fbox{ %s \\parbox[b]{.765\\textwidth}{ \\begin{center} %s \\end{center} } }\\\\* \\vspace{-0.75em}\\\\* \\noindent %s \\strut ''' % ( icon if icon else b'', b'\\\\*'.join(self._texifier.texify(x) for x in meta if x is not None), self._texifier.texify(chunk.content))