fixes for plain text bbcode rendering

This commit is contained in:
Dylan Knutson
2025-02-28 21:26:20 +00:00
parent 87993562eb
commit 398abf48a7
20 changed files with 743 additions and 136 deletions

View File

@@ -55,7 +55,9 @@ RUN \
watchman \
ffmpeg \
ffmpegthumbnailer \
abiword
abiword \
pdftohtml \
libreoffice
# Install postgres 15 client
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \

View File

@@ -60,7 +60,9 @@ RUN \
libpq-dev \
ffmpeg \
ffmpegthumbnailer \
abiword
abiword \
pdftohtml \
libreoffice
COPY --from=native-gems /usr/src/app/gems/xdiff-rb /gems/xdiff-rb
COPY --from=native-gems /usr/src/app/gems/rb-bsdiff /gems/rb-bsdiff

View File

@@ -51,3 +51,7 @@
.log-entry-table-row-cell {
@apply flex items-center border-b border-slate-200 px-2 py-1 text-sm group-hover:bg-slate-50;
}
#rich-text-content blockquote {
@apply my-4 border-s-4 border-gray-300 bg-slate-200 p-4 italic leading-relaxed;
}

View File

@@ -142,6 +142,7 @@ class Domain::UsersController < DomainController
error: "user '#{exclude_url_name}' not found",
error_type: "exclude_user_not_found",
}
return
elsif exclude_followed_by.scanned_follows_at.nil?
render status: 500,
json: {
@@ -149,6 +150,7 @@ class Domain::UsersController < DomainController
"user '#{exclude_url_name}' followers list hasn't been scanned",
error_type: "exclude_user_not_scanned",
}
return
else
not_followed_similar_users =
users_similar_to_by_followers(

View File

@@ -85,37 +85,48 @@ module Domain::PostsHelper
return nil unless file.state_ok?
return nil unless file.log_entry_id.present?
content_type = file.log_entry&.content_type || ""
pretty_content_type(content_type)
end
ct =
case content_type
when %r{text/plain}
"Text Document"
when %r{application/pdf}
"PDF Document"
when %r{image/jpeg}
"JPEG Image"
when %r{image/png}
"PNG Image"
when %r{image/gif}
"GIF Image"
when %r{video/webm}
"Webm Video"
when %r{audio/mpeg}
"MP3 Audio"
when %r{audio/mp3}
"MP3 Audio"
when %r{audio/wav}
"WAV Audio"
when %r{application/msword}
"Microsoft Word Document"
else
content_type.split(";").first&.split("/")&.last&.titleize
end
sig { params(content_type: String).returns(String) }
def pretty_content_type(content_type)
case content_type
when %r{text/plain}
"Plain Text Document"
when %r{application/pdf}
"PDF Document"
when %r{application/msword}
"Microsoft Word Document"
when %r{application/vnd\.openxmlformats-officedocument\.wordprocessingml\.document}
"Microsoft Word Document (OpenXML)"
when %r{application/rtf}
"Rich Text Document"
when %r{image/jpeg}
"JPEG Image"
when %r{image/png}
"PNG Image"
when %r{image/gif}
"GIF Image"
when %r{video/webm}
"Webm Video"
when %r{audio/mpeg}
"MP3 Audio"
when %r{audio/mp3}
"MP3 Audio"
when %r{audio/wav}
"WAV Audio"
else
content_type.split(";").first&.split("/")&.last&.titleize || "Unknown"
end
end
file_size =
file.log_entry&.response_size&.then { |size| number_to_human_size(size) }
[ct, file_size].compact.join(", ")
sig { params(post: Domain::Post).returns(T.nilable(String)) }
def gallery_file_size_for_post(post)
file = post.primary_file_for_view
return nil unless file.present?
return nil unless file.state_ok?
return nil unless file.log_entry_id.present?
file.log_entry&.response_size&.then { |size| number_to_human_size(size) }
end
sig { params(url: String).returns(T.nilable(String)) }
@@ -318,7 +329,11 @@ module Domain::PostsHelper
# normalize the source to a lowercase string with a protocol
source.downcase!
source = "https://" + source unless source.include?("://")
uri = URI.parse(source)
begin
uri = URI.parse(source)
rescue StandardError
return nil
end
uri_host = uri.host
return nil if uri_host.blank?

View File

@@ -12,17 +12,29 @@ module Domain::UsersHelper
).returns(String)
end
def domain_user_avatar_img_src_path(avatar, thumb: nil)
if (sha256 = avatar&.log_entry&.response_sha256)
Rails.application.routes.url_helpers.blob_path(
HexUtil.bin2hex(sha256),
format: "jpg",
thumb: thumb,
)
else
# default / 'not found' avatar image
# "/blobs/9080fd4e7e23920eb2dccfe2d86903fc3e748eebb2e5aa8c657bbf6f3d941cdc/contents.jpg"
asset_path("user-circle.svg")
end
cache_key = ["domain_user_avatar_img_src_path", avatar&.id, thumb]
Rails
.cache
.fetch(cache_key, expires_in: 1.day) do
if (sha256 = avatar&.log_entry&.response_sha256)
Rails.application.routes.url_helpers.blob_path(
HexUtil.bin2hex(sha256),
format: "jpg",
thumb: thumb,
)
elsif avatar && avatar.state_file_404? &&
(sha256 = avatar.last_log_entry&.response_sha256)
Rails.application.routes.url_helpers.blob_path(
HexUtil.bin2hex(sha256),
format: "jpg",
thumb: thumb,
)
else
# default / 'not found' avatar image
# "/blobs/9080fd4e7e23920eb2dccfe2d86903fc3e748eebb2e5aa8c657bbf6f3d941cdc/contents.jpg"
asset_path("user-circle.svg")
end
end
end
sig do
params(

View File

@@ -62,6 +62,17 @@ module LogEntriesHelper
content_type.starts_with?("application/json")
end
sig { params(content_type: String).returns(T::Boolean) }
def is_rich_text_content_type?(content_type)
%w[
application/pdf
application/rtf
application/msword
text/plain
application/vnd.openxmlformats-officedocument.wordprocessingml.document
].any? { |ct| content_type.starts_with?(ct) }
end
sig { params(content_type: String).returns(T::Boolean) }
def is_renderable_video_type?(content_type)
%w[video/mp4 video/webm].any? { |ct| content_type.starts_with?(ct) }
@@ -85,23 +96,55 @@ module LogEntriesHelper
is_renderable_image_type?(content_type)
end
sig { params(log_entry: HttpLogEntry).returns(T.nilable(String)) }
def render_msword_content(log_entry)
docx_body = log_entry.response_bytes
return nil if docx_body.blank?
# Invoke abiword to convert doc / docx to html
# Run abiword conversion with pipes
stdin, stdout, wait_thr =
Open3.popen2(
sig { params(rich_text_body: String).returns(T.nilable(String)) }
def convert_with_pdftohtml(rich_text_body)
stdin, stdout, stderr, wait_thr =
Open3.popen3(
"pdftohtml",
"-i", # ignore images
"-s", # generate single HTML page
"-nodrm", # ignore drm
"-enc",
"UTF-8",
"-stdout",
"-", # read from stdin
"-", # write to stdout (???)
)
stdin.binmode
stdin.write(rich_text_body)
stdin.close
stdout_str = stdout.read
exit_status = T.cast(wait_thr.value, Process::Status)
return nil unless exit_status.success?
# For PDFs, handle both HTML entities and Unicode NBSPs
# First replace the actual unicode NBSP character (U+00A0)
# stdout_str.gsub!(/[[:space:]]+/, " ")
stdout_str.gsub!(/\u00A0/, " ")
stdout_str.gsub!(/&nbsp;/i, " ")
stdout_str.gsub!(/&#160;/, " ")
stdout_str.gsub!(/&#xA0;/i, " ")
stdout_str
ensure
stdin&.close
stdout&.close
stderr&.close
end
sig { params(rich_text_body: String).returns(T.nilable(String)) }
def convert_with_abiword(rich_text_body)
stdin, stdout, stderr, wait_thr =
Open3.popen3(
"abiword",
"--display=0",
"--to=html",
"--to-name=fd://1",
"fd://0",
)
stdin.binmode
stdin.write(docx_body)
stdin.write(rich_text_body)
stdin.close
stdout_str = stdout.read
@@ -109,29 +152,164 @@ module LogEntriesHelper
return nil unless exit_status.success?
stdout_str.gsub!(/Abiword HTML Document/, "")
stdout_str = T.cast(T.unsafe(stdout_str).bbcode_to_html(false), String)
stdout_str = try_convert_bbcode_to_html(stdout_str)
stdout_str.gsub!(%r{<br\s*/>}, "")
sanitizer =
Sanitize.new(
elements: %w[span div p i b strong em],
attributes: {
"span" => %w[style],
"div" => %w[style],
"p" => %w[style],
"b" => %w[style],
"i" => %w[style],
"strong" => %w[style],
"em" => %w[style],
},
css: {
properties: %w[font-size color text-align margin-bottom],
},
)
raw sanitizer.fragment(stdout_str)
stdout_str
ensure
stdin&.close
stdout&.close
stderr&.close
end
sig { params(rich_text_body: String).returns(T.nilable(String)) }
def convert_with_libreoffice(rich_text_body)
tempfile = Tempfile.new(%w[test .doc], binmode: true)
tempfile.write(rich_text_body)
tempfile.flush
stdin, stdout, stderr, wait_thr =
Open3.popen3(
"libreoffice",
"--display",
"0",
"--headless",
"--convert-to",
"html",
T.must(tempfile.path),
"--cat",
)
stdin.binmode
stdin.write(rich_text_body)
stdin.close
stdout_str = stdout.read
exit_status = T.cast(wait_thr.value, Process::Status)
return nil unless exit_status.success?
stdout_str
ensure
stdin&.close
stdout&.close
stderr&.close
tempfile&.close
end
sig { params(rich_text_body: String).returns(String) }
def try_convert_bbcode_to_html(rich_text_body)
rich_text_body.bbcode_to_html(false)
rescue StandardError
rich_text_body
end
sig { params(log_entry: HttpLogEntry).returns(T.nilable(String)) }
def render_rich_text_content(log_entry)
content_type = log_entry.content_type
rich_text_body = log_entry.response_bytes
return nil if rich_text_body.blank? || content_type.blank?
is_plain_text = content_type.starts_with?("text/plain")
if is_plain_text
# rich_text_body.gsub!(/(\r\n|\n|\r)+/, "<br />")
rich_text_body = rich_text_body.force_encoding("UTF-8")
document_html = try_convert_bbcode_to_html(rich_text_body)
elsif content_type.starts_with?("application/pdf")
document_html = convert_with_pdftohtml(rich_text_body)
else
document_html =
convert_with_abiword(rich_text_body) ||
convert_with_libreoffice(rich_text_body)
end
return nil if document_html.blank?
quote_transformer =
Kernel.lambda do |env|
node = env[:node]
if node["class"]
classes = node["class"].split(" ").map(&:strip).compact
node.remove_attribute("class")
if classes.include?("quote")
# write div to be a blockquote
node.name = "blockquote"
end
end
node
end
clean_plain_text_node =
Kernel.lambda do |node|
if node.text?
node_text = node.text.strip
if node_text.empty?
node.unlink
else
# collect all the subsequent nodes that are not a block element
# and replace the current node with a <p> containing the text
# and the collected nodes
current_node = node
inline_elements = []
while (next_sibling = current_node&.next_sibling) &&
(next_sibling.name != "br") && (next_sibling.name != "p")
inline_elements << next_sibling
current_node = next_sibling
end
node_html = [node_text]
inline_elements.each do |inline_element|
inline_element.unlink
node_html << inline_element.to_html
end
node.replace("<p>#{node_html.join(" ")}</p>")
end
end
end
plain_text_transformer =
Kernel.lambda do |env|
# within a div, wrap bare text nodes in a <p>
node = env[:node]
node_name = env[:node_name]
if node_name == "div"
node.children.each { |child| clean_plain_text_node.call(child) }
elsif node.text? && node.parent&.name == "#document-fragment"
clean_plain_text_node.call(node)
end
{ node_allowlist: [node] }
end
remove_empty_newline_transformer =
Kernel.lambda do |env|
node = env[:node]
node.unlink if node.text? && node.text.strip.chomp.blank?
end
remove_multiple_br_transformer =
Kernel.lambda do |env|
node = env[:node]
if node.name == "br"
node.unlink if node.previous_sibling&.name == "br"
end
end
sanitizer =
Sanitize.new(
elements: %w[span div p i b strong em blockquote br],
attributes: {
all: %w[style class],
},
css: {
properties: %w[color text-align margin-bottom],
},
transformers: [
quote_transformer,
is_plain_text ? remove_empty_newline_transformer : nil,
is_plain_text ? plain_text_transformer : nil,
is_plain_text ? remove_multiple_br_transformer : nil,
].compact,
)
raw sanitizer.fragment(document_html).strip
end
end

View File

@@ -8,8 +8,8 @@ module Domain::NeighborFinder
.returns(ActiveRecord::Relation)
end
def self.find_neighbors(factors)
Domain::Factors.connection.execute("SET ivfflat.max_probes = 10")
Domain::Factors.connection.execute("SET ivfflat.probes = 10")
factors.nearest_neighbors(:embedding, distance: "cosine")
Domain::Factors.connection.execute("SET ivfflat.max_probes = 32")
Domain::Factors.connection.execute("SET ivfflat.probes = 32")
factors.nearest_neighbors(:embedding, distance: "euclidean")
end
end

View File

@@ -31,7 +31,7 @@ class Scraper::FaHttpClientConfig < Scraper::HttpClientConfig
def ratelimit
# number represents minimum delay in seconds between requests to the same domain
[["d.furaffinity.net", :none], ["*.facdn.net", :none], ["*", 1]]
[["d.furaffinity.net", :none], ["*.facdn.net", :none], ["*", 1.5]]
end
def allowed_domains

View File

@@ -18,7 +18,7 @@
alt: post.title_for_view %>
<% end %>
<% elsif file_info = gallery_file_info_for_post(post) %>
<span class="text-sm text-slate-500 italic"><%= file_info %></span>
<span class="text-sm text-slate-500 italic"><%= file_info %>, <%= gallery_file_size_for_post(post) %></span>
<% else %>
<span class="text-sm text-slate-500 italic">No file available</span>
<% end %>

View File

@@ -10,8 +10,8 @@
<%= render partial: "log_entries/renderers/embed", locals: { log_entry:, path: } %>
<% elsif is_json_content_type?(log_entry.content_type) %>
<%= render partial: "log_entries/renderers/json", locals: { log_entry:, path: } %>
<% elsif log_entry.content_type =~ %r{application/msword} %>
<%= render partial: "log_entries/renderers/msword", locals: { log_entry:, path: } %>
<% elsif is_rich_text_content_type?(log_entry.content_type) %>
<%= render partial: "log_entries/renderers/rich_text", locals: { log_entry:, path: } %>
<% else %>
<%= render partial: "log_entries/renderers/iframe_fallback", locals: { log_entry:, path: } %>
<% end %>

View File

@@ -1,30 +0,0 @@
<section class="sky-section w-full">
<div class="section-header flex justify-between items-center sticky z-10 py-2 border-b">
<span>Word Document</span>
<button onclick="toggleMsWordContent()" id="msword-toggle" class="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1">
<span>Show More</span>
<i id="msword-toggle-icon" class="fa-solid fa-chevron-down w-4 h-4"></i>
</button>
</div>
<div class="bg-slate-100 p-4 indent-4 font-medium font-serif">
<div id="msword-content" class="line-clamp-6">
<%= render_msword_content(log_entry) %>
</div>
<script>
function toggleMsWordContent() {
const content = document.getElementById('msword-content');
const button = document.getElementById('msword-toggle');
const icon = document.getElementById('msword-toggle-icon');
if (content.classList.contains('line-clamp-6')) {
content.classList.remove('line-clamp-6');
button.firstElementChild.textContent = 'Show Less';
icon.style.transform = 'rotate(180deg)';
} else {
content.classList.add('line-clamp-6');
button.firstElementChild.textContent = 'Show More';
icon.style.transform = 'rotate(0deg)';
}
}
</script>
</div>
</section>

View File

@@ -0,0 +1,86 @@
<section class="sky-section w-full">
<% rich_text_content = render_rich_text_content(log_entry) %>
<div class="section-header flex justify-between items-center sticky z-10 py-2 border-b">
<span><%= pretty_content_type(log_entry.content_type) %></span>
<div class="flex items-center gap-3">
<% if rich_text_content.present? %>
<div class="flex items-center gap-2">
<span id="font-size-percentage" class="text-sm text-gray-600 font-medium">100%</span>
<button onclick="decreaseFontSize()" class="text-sm text-blue-600 hover:text-blue-800 flex items-center justify-center bg-gray-100 border border-gray-300 rounded-md h-6 w-6 shadow-sm hover:bg-gray-200">
<i class="fa-solid fa-minus"></i>
</button>
<button onclick="increaseFontSize()" class="text-sm text-blue-600 hover:text-blue-800 flex items-center justify-center bg-gray-100 border border-gray-300 rounded-md h-6 w-6 shadow-sm hover:bg-gray-200">
<i class="fa-solid fa-plus"></i>
</button>
</div>
<button onclick="toggleRichTextContent()" id="rich-text-toggle" class="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1 bg-gray-100 border border-gray-300 rounded-md px-2 py-1 shadow-sm hover:bg-gray-200">
<span>Show More</span>
<i id="rich-text-toggle-icon" class="fa-solid fa-chevron-down w-4 h-4"></i>
</button>
<% end %>
</div>
</div>
<% if rich_text_content.present? %>
<div class="bg-slate-100 p-4 indent-4 font-medium font-serif">
<div id="rich-text-content" class="line-clamp-6 leading-loose">
<%= rich_text_content %>
</div>
<script>
// Store the base font size when page loads
let baseFontSize;
document.addEventListener('DOMContentLoaded', function() {
const content = document.getElementById('rich-text-content');
baseFontSize = parseFloat(window.getComputedStyle(content).fontSize);
updateFontSizePercentage(baseFontSize);
});
function toggleRichTextContent() {
const content = document.getElementById('rich-text-content');
const button = document.getElementById('rich-text-toggle');
const icon = document.getElementById('rich-text-toggle-icon');
if (content.classList.contains('line-clamp-6')) {
content.classList.remove('line-clamp-6');
button.firstElementChild.textContent = 'Show Less';
icon.style.transform = 'rotate(180deg)';
} else {
content.classList.add('line-clamp-6');
button.firstElementChild.textContent = 'Show More';
icon.style.transform = 'rotate(0deg)';
}
}
function increaseFontSize() {
const content = document.getElementById('rich-text-content');
const currentSize = window.getComputedStyle(content).fontSize;
const newSize = Math.min(parseFloat(currentSize) + 2, 22); // Don't allow larger than 22px
content.style.fontSize = `${newSize}px`;
updateFontSizePercentage(newSize);
}
function decreaseFontSize() {
const content = document.getElementById('rich-text-content');
const currentSize = window.getComputedStyle(content).fontSize;
const newSize = Math.max(parseFloat(currentSize) - 2, 12); // Don't allow smaller than 12px
content.style.fontSize = `${newSize}px`;
updateFontSizePercentage(newSize);
}
function updateFontSizePercentage(currentSize) {
// If baseFontSize isn't set yet, set it
if (!baseFontSize) {
baseFontSize = currentSize;
}
const percentageElement = document.getElementById('font-size-percentage');
const percentage = Math.round((currentSize / baseFontSize) * 100);
percentageElement.textContent = `${percentage}%`;
}
</script>
</div>
<% else %>
<div class="bg-slate-100 p-4 text-slate-500 italic text-center">
<span>Error rendering document</span>
</div>
<% end %>
</section>

View File

@@ -98,6 +98,10 @@
>
</div>
</div>
<div class="text-right text-sm text-slate-500">SHA256</div>
<div class="text-sm">
<span class="font-mono text-slate-500"><%= HexUtil.bin2hex(@log_entry.response_sha256) %></span>
</div>
<div class="text-right text-sm text-slate-500">Content</div>
<div class="text-sm">
<span class="font-mono"><%= @log_entry.content_type %></span>

View File

@@ -67,12 +67,11 @@ Rails.application.configure do
config.log_tags = [:request_id]
# Use a different cache store in production.
config.cache_store = :memory_store, { size: 64.megabytes }
config.cache_store = :file_store, "/mnt/rails-cache"
# Use a real queuing backend for Active Job (and separate queues per environment).
# config.active_job.queue_adapter = :resque
# config.active_job.queue_name_prefix = "legacy_explorer_production"
config.action_mailer.perform_caching = false
# Ignore bad email addresses and do not raise email delivery errors.
@@ -99,22 +98,6 @@ Rails.application.configure do
config.logger = ActiveSupport::TaggedLogging.new(logger)
end
config.log_tags = {
ip: ->(request) do
request.headers["HTTP_CF_CONNECTING_IP"] || request.remote_ip
end,
api_token: ->(request) { request.params[:api_token] || "(nil api token)" },
user_name: ->(request) do
api_token = request.params[:api_token]
if api_token
user = Domain::Fa::ApiController::API_TOKENS[api_token]
user || "(nil user)"
else
"(nil api_token)"
end
end,
}
# Do not dump schema after migrations.
config.active_record.dump_schema_after_migration = false
end

View File

@@ -0,0 +1,18 @@
# typed: strict
class String
sig do
params(
escape_html: T::Boolean,
additional_tags: T::Hash[Symbol, T.untyped],
method: Symbol,
tags: T.untyped,
).returns(String)
end
def bbcode_to_html(
escape_html = true,
additional_tags = {},
method = :disable,
*tags
)
end
end

View File

@@ -1,26 +1,35 @@
require "rails_helper"
RSpec.describe LogEntriesHelper, type: :helper do
describe "#render_msword_content" do
let(:log_entry) do
describe "#render_rich_text_content" do
def build_log_entry(content_type, fixture_path, binary: true)
build(
:http_log_entry,
content_type: content_type,
response:
build(
:blob_file,
content_type: "application/msword",
content_type: content_type,
contents:
File.binread(
Rails.root.join(
"test/fixtures/files/5447897_Thakur_trials_13.doc",
),
(
if binary
File.binread(Rails.root.join(fixture_path))
else
File.read(Rails.root.join(fixture_path))
end
),
),
)
end
it "returns the converted HTML content" do
rendered = helper.render_msword_content(log_entry)
it "works with msword files" do
log_entry =
build_log_entry(
"application/msword",
"test/fixtures/files/5447897_Thakur_trials_13.doc",
)
rendered = helper.render_rich_text_content(log_entry)
expect(rendered).not_to be_nil
# no abiword header
expect(rendered).not_to include("Abiword HTML Document")
@@ -35,5 +44,40 @@ RSpec.describe LogEntriesHelper, type: :helper do
expect(rendered).to include("The next morning")
end
it "works with other doc files" do
log_entry =
build_log_entry(
"application/msword",
"test/fixtures/files/1665721884.wolfsnack.writer_doc.doc",
)
rendered = helper.render_rich_text_content(log_entry)
expect(rendered).not_to be_nil
expect(rendered).to include("Now, America")
expect(rendered).to include("Years passed.")
end
it "works with pdf files" do
log_entry =
build_log_entry(
"application/pdf",
"test/fixtures/files/1677472939.wolfsnack.writer_maze_of_the_wolf.pdf",
)
rendered = helper.render_rich_text_content(log_entry)
expect(rendered).not_to be_nil
expect(rendered).to include("The Garden")
expect(rendered).to include("Will promised -- as best he could")
end
it "parses bbcode in plain text" do
log_entry =
build_log_entry(
"text/plain",
"test/fixtures/files/1674254968.darkviolet_firedancer_text.txt",
binary: false,
)
rendered = helper.render_rich_text_content(log_entry)
expect(rendered).not_to be_nil
end
end
end

Binary file not shown.

View File

@@ -0,0 +1,287 @@
[quote][center]Hello, and welcome to Weststate News at 7.
The Prairietown Arsonist has struck again. The Towerbridge Mall was discovered ablaze at a quarter past four this morning; local police were immediately called to the scene, but no trace of the arsonist were found. The local fire department has been fighting the blaze; the latest reports are that they have managed to stop the spread, but the building - the first mall of its kind in the town - could not be saved.
Local sheriff departments say they that the case for the Prairietown Arsonist is ongoing, but little progress has been made, and has once again asked for appeals for any information from the public...[/center][/quote]
[center][b]Firedancer[/b]
[i]A Someones PC Master Tier tale[/i]
[i]By Dark Violet[/i]
[i]Requested by Luster Unicorn[/i][/center]
The weak morning sunlight peeked through the blackened timbers, twisted and stark against the pale spring sky. Their shadows stretched like fingers across what remained of the old Weskers inn, dull, frozen facsimiles of the flames that had torn through it just hours earlier.
Brendan wiped the soot from his visor as he peered through the misshapen first-floor windows at the broken rubble inside, encrusted with snow-white ash. White smoke and steam drifted in the still air, and humid warmth radiated even through his suit.
“Brendan!”
He started from his reverie, turning around. Connie was picking her way through the dry, cracked remains of the front lawn.
“Brendan, hunny, you deaf? I asked if ywere okay.”
Brendan stepped back towards her. “Yeah, Im fine,” he lied. He tried to cross his arms; they ached like they had leaden weights on the wrists.
“Sure yare. Just like anyone after five hours fightin a blaze.”
He pulled off a glove slowly. “Five hours…?” he muttered, reaching under his visor to rub at his eyes. “Fuckin... thought it was less than that.”
Connie patted his arm. “Sun dont lie. Cmon. You need water.”
They picked their way over the debris of the front lawn. Their firetruck was parked askew, halfway onto the lawn, its faded red coat barely visible under decades of caked-on mud. The young Declan was already by it with Wavesurfer, the Greninja, winding the hoses back up. As Connie and Brendan walked past them, Declan was nudging one of the Squirtle, ash-smeared and half-retreated into its shell, laying on the ragged hoseline. It regarded him with an exhausted stare before rolling off.
Brendan and Connie reached the truck, walking to the wide-open door on the passenger side. Connie heaved a dusty plastic bottle out from under the seat, and pulled off the lid.
“Drink,” she ordered.
Brendan hesitated only a moment, before taking the bottle and swigging. The water was warm, but was still a blessing on his parched tongue.
“Attaboy.”
Brendan passed the bottle back to her, and gasped for air, dribbles of water running down his chin. He slumped back onto the ledge of the firetruck. “You know it was nineteenth century, that one?” he said, staring at the skeleton of the old building.
“Id believe it. Would be concrete otherwise, and then it mighta stood a chance.” Connie sat down next to him, the water bottle perched on her lap.
“Yknow, thats what really gets me?” he said, taking off his other glove and dropping the pair onto the ledge next to them. “Its always old buildings. Antiques. He aint burnin down that local eyesore peoplere calling a Mart, is he? Its always some treasure or old family home.”
Least no-ones in em, right?”
Brendan grunted. “Still. Cant replace those things. I just wish hed go and burn somethin... less… irreplaceable.” He realised he was fingering his wedding ring, twisting it around his finger.
“Look at it this way, hunny,” Connie said, slapping his shoulder. “He keeps this up, we wont be out of a job anytime soon.”
Brendan sighed and closed his eyes. “Connie, were fuckin volunteers.”
Connie snickered to herself, and then silence fell, save for the whispering of the morning breeze in the trees and rustling and clunking as Declan and Wavesurfer continued packing up the rest of their kit.
“I hear ya though, Brendan,” Connie said at last, leaning back with him. “I mean, whoever this arsonist is, he could at least give us a break. Bad enough this being the… whatre we up to now since January? Thirty seven?”
“Thirty eight,” Brendan said automatically. “The old bird tower over Prairietown-ways burned down on Monday.”
“Arceus, youre kiddin. Used to climb that thing when I was a kid. Those Pidgeotto get real nasty when you try and steal their eggs. Still got the scars to prove it!” She rolled up her sleeve, gleefully flashing the raised white line down her forearm.
Brendan glanced at her, his throat tightening.
Connie must have seen his expression, and rolled her sleeve back down. “Sorry. That aint appropriate no more, I know. I wouldnt do that today, you know that. And I dont mean no offence to your missus, now.”
Brendan nodded, but didnt say anything. They returned to silence, looking across at the remains of the inn.
“Well…” Brendan muttered, looking down. “I should get back to the station, start on the paperwork…”
Connie waved her hand in front of him. “Brendan, you were up last night with the ORileys old place, and now youve been called out of your bed tonight too. Now I know ya got a day off today. If anyone deserves rest, its you.”
Brendan bit his lip. “Connie, its okay. I-”
“Nah, not hearing it. Look, Ill do the paperwork. Anyway, I gotta be at the station today. Gonna be showing Comet the ropes. Literally!” She cackled, elbowing him. “Geddit? Cause es a Sawsbuck, and hes gonna be… no? Nothin?”
Brendan waved a hand.
“Hey,” she said, her voice suddenly a lot lower than it had been. “Look, were gonna catch this sonuvabitch. PDs got their growlithe out sniffing every road from here to the Blue Mountains and back. Psychics tryin ta pin down the next hit. Its only a matter of time, yknow that.”
Brendan stared at the ground, his eyes unfocused. He was fiddling with his wedding ring again, and forced his hands back on his knees.
“Yeah, yeah I know,” he said eventually.
“Good on ya. Go on, get on back to the missus. I know shell be missin ya.”
She patted him again, and got up. She made her way over towards Declan and Wavedancer, and set about helping them with the rest of the tear-down.
Meanwhile, Brendans heart burned.
After a short while, he got up too, and made his way over to his truck. He packed up his suit, gloves, and helmet into a case in the truckbed, tightened the straps around it, and heaved himself into the cab. The engine grumbled into life.
He didnt remember the drive home.
It was almost mid-morning by the time he pulled into his driveway. He cut the engine early in order to coast in, and made sure not to unpack anything just yet. He eased the front door closed behind him, and crept up the stairs, making sure to avoid the squeaky floorboards. He let out a slow breath when he got to the bedroom door.
He pushed open the door, and there she was.
The curtains were drawn, only the barest slivers of light illuminating their edges, but the hallway was sunlit from the nearby window; those rays were all that was needed to shine through the now-open doorway and land on the shining orange mane that spilled out over the covers. The comforter was tight around her, showing off the smooth curve of chest and haunches, dipping to her thin waist; the blankets rose and fell with each rolling, deep purr that was her version of snoring. Her scent, the heady, musty feline aura that seemed to permeate her, tickled his nose.
Others said it was just like any Pyroars scent; but no other Pyroar sent Brendans heart fluttering quite like she did.
For a moment, everything else faded away - the fires, the worries, all of it. And, like hed done every morning for seven years, he wondered how hed ever managed to get such a wonderful woman.
Languorously, Firedancer rolled over, and her snub muzzle poked out from behind the flowing locks of her mane. She opened a lazy, topaz eye, and let out a purring [i]rrrrroowwwlll[/i][i] [/i]of greeting.
Her nose twitched; the smell of ash and smoke clung to him, permeating the bedroom air. She looked him up and down, and a smile twinkled at the corner of her lips.
At that sight, any weight still in his chest lifted completely. Hed already started to forget how it felt as he shut the door, and climbed into bed behind his wife, sliding his arms around her and holding her tightly in his arms. His tired heart leapt as she nuzzled close to him.
[center]* * *[/center]
When he woke up later, Firedancer was still half-curled around him, head twisted awkwardly and long legs everywhere, with her mane tumbling over his chest. It was like all the tension had drained from Brendans body, like water running off his fingertips, and the only reason he didnt chuckle in relief was tiredness. Through a sliver between the curtains, he could see an orange sky, while a sliver of golden sunset ran across the far wall.
Eventually, he got up, and set about making them both dinner - meatloaf with whole hunks of meat, just slightly overdone, just like she liked it. Theyd relaxed on the couch while channel flipping, the light of the TV gleaming off their wedding rings. It was calm and quiet and effortless, his fingers lazily rubbing the back of her neck, her nose gently nuzzling his chest.
But as the hours went on, Brendan realised he was ignoring how Firedancers paws twitched beneath him, how even now her smile had faded since the morning. As the clock lazily ticked on into the dark hours of the evening, he noticed that her tail was starting to lazily swing back and forth. He pushed it to the back of his mind - instead, he got up, gave her a kiss on the snout, and went to wash up.
He was scrubbing the tray from the meatloaf when she showed up at the door, mane rolling down the side of her head.
Brendan continued scrubbing - or, at least, tried to, tried to keep his mind off her. But he could never keep his eyes off her for long. He took a breath, attempting to quell the rising anxiousness in his chest.
“Hey honey,” he said, glancing at her. “You okay?”
Firedancer raised her head slowly, and looked at him from down her snout.
The anxiety flared up fully, like a lump in his throat. He scrubbed the tray a few more times. “Hey now, hon, Ive barely got up, and…”
A gentle rolling [i]rrrrrowwwwllll[/i] simmered beneath his voice. Her eyes narrowed.
Brendan put down the tray. “Firedancer…”
The purring growl faded, but left room for it to return. Her gaze never wandered from him.
He turned around. “Come on, hun. Its been twice this week already. Thisd be the third night in a row.”
She stepped into the room, giving a short grunt of acknowledgement.
“Honey, you gotta understand. The PDs on the lookout now. The radios talkin of nothin else. This arsonist is a household name from here to Castelia.”
A smile pulled at her lips. She raised her eyebrows.
“Thats not… [i]fuck…” [/i]Brendan turned back around, putting his hands on the edge of the sink and hanging his head. After a while, he realised he could see her paws from the corner of his eye - she was watching him still. Staring at him.
Fuck.
“Firedancer, this cant be a secret forever. Its only a matter of time. You know that, right?”
Firedancer gave a short, sharp [i]rrowp[/i], followed by a low growl again.
How could she be so Arceus-damn cavalier about this…? Brendan sighed, rubbing his face. There were soft pawsteps behind him, and warmth rolled over his hips.
He turned to face her, and there she was - right in front of him, eyes half-lidded and hopeful smile on her muzzle, mane tossed along her back where it lay like an image of a flame.
“Fire, honey…”
[i]Rrrrroooowwwwll[/i][i]…[/i]
And then she jumped up, and her paws were on his chest, and she looked down at him from above eye-height. He leaned back, almost pressed into the sink, and her scent surrounded him like a warm embrace.
He rested his hands on her neck. She purred, angling her head a touch closer to him, and pleasant trembles ran through his body. He leaned in, raising his head to her lips, only for her to pull back coyly.
[i]Rrowp[/i][i]! [/i]A paw batting gently at his chest. A questioning flick of the ear orchestrated his cue to answer.
“Okay… okay.” His heart danced in flames of love. “...Anythin for you.”
[center]* * *[/center]
It was a cooler night than the rest of the week had been, and the waxing moon was little more than a thin crescent hanging just over the trees. The last of its silver light barely illuminated the backroad through the woods a half-hour south of Prairietown. Fuzzy flickers of shadowed branches trailed across the bonnet of their truck as they bumped down the gravelled path - a rarely used route, and importantly, not being watched by the PD. At least, as far as Brendan knew. He couldnt stop his gaze constantly sweeping the bushes at the edge of the road for any gleams of eyes, or dark, unmarked cars.
Firedancer half-lay on the passenger side, lounging her head out of the window. Locks of orange mane rippled and tumbled in the wind, and every time Brendan looked at her, it was a fight to tear his gaze away. It was just as hard to ignore how her tail flicked lazily against his leg. But he didnt want to ignore everything.
How many more times would he be able to look over at her like this?
He stole another glance. For a moment, the sight of her like that made him remember their first drive, all those years ago. It had been summer, and shed been so fiery and vibrant against the green trees. Shed been introduced to him as a model, and working for herself too, rather than through some trainer - all stuff he had to be reminded of later, because at first, he saw nothing but her beauty, radiating from her like a star. He could barely stammer an introduction. Shed given a trilling chuckle as she sniffed his hand in greeting.
How hed worked up the courage to ask her out on a date, he still didnt know. A drive around the hills to the east, with the wind through her mane, a dazzling smile on her delicate muzzle, and a delirious feeling in his chest - that if he hadnt found [i]the one[/i], then everyone else would be spoiled forever in comparison.
Theyd been married within the year. And it had been… glorious. Everything he had done, it had been for the want of but another smile. She even seemed to enjoy his nervousness, teasing him with her particular, pleasant, familiar trills and purrs. They lived together, slept together, and she even moved into his shop so they could spend every day together.
But over time, the fire in that smile had become harder to light. The nuzzles every time he woke up had become softer. The passion at night had begun to slip away, lost to a deep knowledge of inadequacy, like a pit of ice in his stomach…
Until hed asked what she wanted, back this past New Years Eve. A special place, a date just for her, anything she wanted. Please - he really would do anything, just to see that smile again, as bright and as vibrant as he remembered. Anything for his flame.
And her eyes, oh, how theyd gleamed…
[i]Rrowpp.[/i]
He eased off the pedals, and they began to coast to a stop. The thin moonlight caught the edge of a low wall, overgrown with bushes, while the hulking shape of a building complete with a terraced tower was black against a deep, navy sky.
As they pulled in, the headlights briefly illuminated a makeshift sign hastily hung from one of the gates:
[center][i]To the [/i][i]Prairietown Arsonist![/i]
[i][u]PLEASE[/u][/i]
[i]DONT BURN[/i]
[i]THIS DOWN![/i][/center]
Brendan turned the key in the ignition, plunging them into darkness. He could just make out her silhouette against the window. She was staring at the building.
“This is the old Victoria House,” he muttered. Already, the words were half-hearted. “You know that this was a family home at the turn of the last century? Same family that founded Prairietown.”
Firedancer didnt react. Her tail tip still swayed lazily across his leg.
“They say the architect Samantha Victorias final work was on this place…”
Firedancer turned her head to face him. Her eyes were dark in the shadows, but they looked straight at him.
Brendan took in a deep breath, trying to use it to push down the anxiety rolling around his chest.
“Alright.”
He got out of the truck, and went around to the bed, opening the case strapped there. The faint moonlight caught the reflective coating of his firefighting gear. He hesitated for just a moment, then pushed it aside, and pulled out the bolt cutters, the small can of gasoline, and the pack of lighters. That moon allowed him just enough light as he broke the chain around the gate, pushed past the sign, and set off down the cracked paving stones towards the abandoned building.
It didnt even take five minutes.
He stared at the fire crackling beneath the decking. He watched, motionless, as it caught the dribbles of gasoline, and began to curl its way up the outside beams, before spreading out under the eaves. The smell of smoke was already heavy in his nose.
What the night had previously cloaked, the fire now revealed in dancing light - the expertly carved timbers, the shuttered windows, the architects mark just above the front door. He turned away from it, not wanting to read what it said.
He looked back towards the truck - and in the light of the fire, he saw her.
Firedancer was leaning out of the driver-side window, paws over the edge, kneading at the scratched paintwork. Her wedding ring shone.
He walked back, and climbed back into the truck, unable to take his eyes off her for more than a second.
The Pyroar was lit up in brilliant orange. On her matte coat, shadows traced the slight curves of muscle beneath her fur, while her mane sparkled. She turned to face him, and there was that smile again - broad, knowing, gleeful. And there they went - his doubts, his anxieties, they all melted away beneath that smile. She lowered her head, and from beneath a quiff of that shining yellow mane that shaded half her face, she looked him over with lidded, twinkling eyes,
He took her head in his hands, and they nuzzled. Her purr rolled down his body, making his fingers quiver as he cupped her cheek, and they kissed. Their tongues danced, his broad and thick, hers flat and rough, each rolling around and tasting and enjoying the other.
The fire was roaring now, the sound of rushing air and crackling timbers flowing across them as the second storey caught alight. Their paws, their hands, roamed each other. Firedancers claws pressed through the fabric of his shirt, and he grabbed at her back, running his fingers over her spine, down to her waist, while her tail curled around his legs. And all the while, that purr, that vibrating purr, rolled through her body like waves of pleasure.
He unhooked his jeans with one hand, pushing his boxers out of the way. He broke the kiss, pressing his forehead against hers, catching brief glimpses of her smoky eyes and broad, ecstatic grin. She gave another [i]rrrraawl[/i], a happy and delighted sound, as she leaned back and flashed fangs behind her black lips. He kissed those lips, pressing his tongue between those sharp, pointed teeth, and she let him. She squirmed indulgently, even as he grabbed his shaft, rigid, ready, and pressed the tip to her small, wet, teardrop of a slit.
The low roar she made when he slid into her made him gasp in delight. Her lips pulled back, her whiskers dancing, fangs bared. He kissed her again, awkwardly, his mouth small compared to her wide maw, and she kissed back, letting those same fangs drag over the skin of his cheeks. He grasped behind her head with one hand, while the other held her waist, as he thrusted with wild, indulgent abandon.
There was a distant crunch as something fell inside the house, and light danced across the trucks cabin. Something shattered the tinkling sound lost in the dull roar and crackles of the flames. The heat of the fire blazed through the open window, but the smell of smoke played second fiddle to Firedancers rolling, intoxicating scent.
Firedancers paws moved to his neck now, claws still teasing the skin, sending sharp shivers of pain through him as she tugged at the barely healed scratches from the previous night. But he didnt care, because those paws were holding him there, and she was hungrily dragging her fangs across his face, tongue delving deep into his mouth. Her eyelashes batted his thumb as he caressed her cheek, and he angled his hips as he thrust deeper, faster.
Brendan leaned back, breaking the hungry kiss, and stared down at Firedancer, at his wife, at this beauty he could never live up to deserving, illuminated in the firelight. She held his neck in her front paws, fixing him with a sultry gaze, as if to hold him there with that look alone. She rolled her hips, hind legs waving on either side of him, rolling her body indulgently around his shaft. Her belly, that normal cream colour lost in the flickering orange, had every contour and muscle highlighted. It showed the supple curves, the nipples peeking through the thin fur, and the way the lines of fur and muscle and contours of her thighs all seemed to point right down to her crotch - where wet, coarse fur didnt quite hide his shaft stretching pink, gleaming lips, or the gentle bulge just above them.
She was no delicate flower - no petite human woman, or some coquettish Braixen - there was a firmness, a ripple of muscles beneath her lithe form, a domineering power to her that he had tried to resist quailing before, and had not yet managed. She was incredible, incandescent, and the light of the growing inferno danced and pirouetted across her body in beautiful, bewitching patterns. Before he knew it, she was pulling him in for another passionate, feral kiss, her legs spreading further and her tail curling tightly around his waist to hold him in, begging for more with each shuddering, delighting stroke.
Pleasure mounted, stoked by her grabbing paws, her writhing body. Another crash from the burning house, a rush of heat over them that was nothing but a delightful reminder. Brendan ran his fingers over her face as they kissed again. He thrust into her, and she pressed back, wrapping her hind legs around his hips to pull him in with each stroke. The moment had taken him over; they gasped, muttering incomprehensible words in their own tongues, half-heard above their twin panting. He rubbed his hands up along neck, over her jaw, fingers running through her thin, velvet-soft fur. He traced the corners of her lips with his fingertips, found them curled upwards - and thats when the pleasure finally grew to its roaring crescendo, and he gasped into her maw, meeting a sudden rolling, vibrating [i]rrrrrroooooowwwwwwllll[/i].
Paws and hands still roamed each other, clutched, as they both trembled in dancing, explosive ecstasy. His shaft jerked, pulsed, spurt after spurt of seed splashing deep inside her, and each one sent tingling pins and needles down his limbs that made him grab and hold onto the beauty that was his wife, and revel in how she did the same.
The kiss broke, and they gasped together, grabbing trembling breaths in the fiery whirlwind that surrounded them.
As they rolled down from the peak, it was like landing in a bed of hot ash, surrounding them in unparalleled warmth as they panted with foreheads pressed together and broad smiles on their faces.
In the distance, faded, almost forgotten, the fire crackled away.
When Brendan eventually started the engine again, the old Victoria house was already in full conflagration, with the previously dark tower now coated in a dancing layer of flames. Brendan took one final look up at it; near the top of the tower, the intricate wooden carvings of a coat of arms were half-lost behind the inferno, before a window exploded outwards, and it disappeared behind a gout of flame. Firedancer pressed up against his side, staring at it with wide, dark eyes that reflected every last burning timber.
Brendan eased the accelerator down and pulled back into the road. He shifted gears, and the light of the fire gleamed in his rear-view mirror as he drove away. The old Victoria house was pretty remote; the flames might not even be seen for another hour or so. Enough time to get back home, before the department called him onto the scene. Or at least, he hoped so.
Firedancer rested her head against his as the last flickering rays of the fire danced across them through the trees. He sighed happily at the very feel of her against him.
This couldnt last forever. He knew that.
Eventually, every fire burns out.
But for now…
…For now.
[center][i]The End[/i][/center]
[i]This story was inspired by the reporting of Phoebe Judge and Lauren Spohrer in the Podcast Criminal, Episode #166 (On Fire)[/i]