]> git.scottworley.com Git - paperdoorknob/blame - glowfic.py
Uniform icon image size
[paperdoorknob] / glowfic.py
CommitLineData
e6adf6ce
SW
1# paperdoorknob: Print glowfic
2#
3# This program is free software: you can redistribute it and/or modify it
4# under the terms of the GNU General Public License as published by the
5# Free Software Foundation, version 3.
6
7
d2a41ff4 8from abc import ABC, abstractmethod
aa060d9b 9from dataclasses import dataclass
e6adf6ce
SW
10import itertools
11
12from typing import Iterable
13
14from bs4 import BeautifulSoup
15from bs4.element import Tag
16
aa060d9b 17from images import ImageStore
d2a41ff4 18from texify import Texifier
aa060d9b
SW
19
20
21@dataclass(frozen=True)
22class Chunk:
23 icon: str | None
24 character: str | None
25 screen_name: str | None
26 author: str | None
27 content: Tag
28
e6adf6ce
SW
29# We avoid the name "post" because the Glowfic community uses the term
30# inconsistently:
31# * The Glowfic software sometimes uses "post" to refer to a whole thread
aa060d9b
SW
32# (in the URL), sometimes uses "post" to refer to chunks (in the CSS),
33# but mostly uses "post" to refer to just the first chunk in a thread
34# (in the HTML and UI). The non-first chunks are "replies".
e6adf6ce
SW
35# * Readers and this software don't need to distinguish first-chunks and
36# non-first-chunks.
aa060d9b 37# * Humans in the community tend to use "posts" to mean chunks.
e6adf6ce
SW
38
39
40def chunkDOMs(html: BeautifulSoup) -> Iterable[Tag]:
41 def text() -> Tag:
42 body = html.body
43 assert body
44 text = body.find_next("div", class_="post-post")
45 assert isinstance(text, Tag)
46 return text
47
48 def the_replies() -> Iterable[Tag]:
49 rs = html.find_all("div", class_="post-reply")
50 assert all(isinstance(r, Tag) for r in rs)
51 return rs
52
53 return itertools.chain([text()], the_replies())
aa060d9b
SW
54
55
56def makeChunk(chunk_dom: Tag, image_store: ImageStore) -> Chunk:
57
58 def getIcon() -> str | None:
59 icon_div = chunk_dom.find_next('div', class_='post-icon')
60 if icon_div is None:
61 return None
62 icon_img = icon_div.find_next('img')
63 if icon_img is None:
64 return None
65 assert isinstance(icon_img, Tag)
66 return image_store.get_image(icon_img.attrs['src'])
67
68 def getTextByClass(css_class: str) -> str | None:
69 div = chunk_dom.find_next('div', class_=css_class)
70 if div is None:
71 return None
72 return div.text.strip()
73
74 content = chunk_dom.find_next('div', class_='post-content')
75 assert isinstance(content, Tag)
76
77 return Chunk(getIcon(),
78 getTextByClass('post-character'),
79 getTextByClass('post-screenname'),
80 getTextByClass('post-author'),
81 content)
d2a41ff4
SW
82
83
c62e8d40
SW
84def renderIcon(icon_path: str | None, image_size: float) -> bytes:
85 return b'\\includegraphics[width=%fmm,height=%fmm,keepaspectratio]{%s}' % (
86 image_size, image_size, icon_path.encode('UTF-8')) if icon_path else b''
d2a41ff4
SW
87
88
89class Layout(ABC):
90
91 @abstractmethod
92 def renderChunk(self, chunk: Chunk) -> bytes:
93 raise NotImplementedError()
94
95
96class ContentOnlyLayout(Layout):
97
98 def __init__(self, texifier: Texifier) -> None:
99 self._texifier = texifier
100
101 def renderChunk(self, chunk: Chunk) -> bytes:
102 return self._texifier.texify(chunk.content)
103
104
105class BelowIconLayout(Layout):
106
c62e8d40 107 def __init__(self, texifier: Texifier, image_size: float) -> None:
d2a41ff4 108 self._texifier = texifier
c62e8d40 109 self._image_size = image_size
d2a41ff4
SW
110
111 def renderChunk(self, chunk: Chunk) -> bytes:
112 icon_width = b'0.25\\textwidth' # TODO: Make this configurable
113 return b'''\\begin{wrapfigure}{l}{%s}
114%s
115%s
116%s
117%s
118\\end{wrapfigure}
119%s
120''' % (
121 icon_width,
c62e8d40 122 renderIcon(chunk.icon, self._image_size),
d2a41ff4
SW
123 chunk.character.encode('UTF-8') if chunk.character else b'',
124 chunk.screen_name.encode('UTF-8') if chunk.screen_name else b'',
125 chunk.author.encode('UTF-8') if chunk.author else b'',
126 self._texifier.texify(chunk.content))