feat!: write language attribute on tags and change document language selection

The document language is now inferred from the first non-artifact text item
and will always be written.
This commit is contained in:
Tobias Schmitz 2025-07-31 17:06:18 +02:00
parent ded845ec34
commit e816af11f7
No known key found for this signature in database
9 changed files with 169 additions and 109 deletions

View File

@ -1,4 +1,4 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::collections::{HashMap, HashSet};
use ecow::{EcoVec, eco_format};
use krilla::configure::{Configuration, ValidationError, Validator};
@ -19,7 +19,7 @@ use typst_library::layout::{
Abs, Frame, FrameItem, GroupItem, PagedDocument, Size, Transform,
};
use typst_library::model::HeadingElem;
use typst_library::text::{Font, Lang};
use typst_library::text::Font;
use typst_library::visualize::{Geometry, Paint};
use typst_syntax::Span;
@ -257,8 +257,6 @@ pub(crate) struct GlobalContext<'a> {
pub(crate) options: &'a PdfOptions<'a>,
/// Mapping between locations in the document and named destinations.
pub(crate) loc_to_names: HashMap<Location, NamedDestination>,
/// The languages used throughout the document.
pub(crate) languages: BTreeMap<Lang, usize>,
pub(crate) page_index_converter: PageIndexConverter,
/// Tagged PDF context.
pub(crate) tags: Tags,
@ -279,7 +277,6 @@ impl<'a> GlobalContext<'a> {
loc_to_names,
image_to_spans: HashMap::new(),
image_spans: HashSet::new(),
languages: BTreeMap::new(),
page_index_converter,
tags: Tags::new(),

View File

@ -1,17 +1,19 @@
use ecow::EcoString;
use krilla::metadata::{Metadata, TextDirection};
use typst_library::foundations::{Datetime, Smart};
use typst_library::foundations::{Datetime, Smart, StyleChain};
use typst_library::layout::Dir;
use typst_library::text::Lang;
use typst_library::text::TextElem;
use crate::convert::GlobalContext;
pub(crate) fn build_metadata(gc: &GlobalContext) -> Metadata {
let creator = format!("Typst {}", env!("CARGO_PKG_VERSION"));
let lang = gc.languages.iter().max_by_key(|&(_, &count)| count).map(|(&l, _)| l);
// Always write a language, PDF/UA-1 implicitly requires a document language
// so the metadata and outline entries have an applicable language.
let lang = gc.tags.doc_lang.unwrap_or(StyleChain::default().get(TextElem::lang));
let dir = if lang.map(Lang::dir) == Some(Dir::RTL) {
let dir = if lang.dir() == Dir::RTL {
TextDirection::RightToLeft
} else {
TextDirection::LeftToRight
@ -20,11 +22,8 @@ pub(crate) fn build_metadata(gc: &GlobalContext) -> Metadata {
let mut metadata = Metadata::new()
.creator(creator)
.keywords(gc.document.info.keywords.iter().map(EcoString::to_string).collect())
.authors(gc.document.info.author.iter().map(EcoString::to_string).collect());
if let Some(lang) = lang {
metadata = metadata.language(lang.as_str().to_string());
}
.authors(gc.document.info.author.iter().map(EcoString::to_string).collect())
.language(lang.as_str().to_string());
if let Some(title) = &gc.document.info.title {
metadata = metadata.title(title.to_string());

View File

@ -1,6 +1,6 @@
use krilla::tagging::{ListNumbering, Tag, TagKind};
use crate::tags::TagNode;
use crate::tags::{GroupContents, TagNode};
#[derive(Clone, Debug)]
pub(crate) struct ListCtx {
@ -10,8 +10,8 @@ pub(crate) struct ListCtx {
#[derive(Clone, Debug)]
struct ListItem {
label: Vec<TagNode>,
body: Option<Vec<TagNode>>,
label: TagNode,
body: Option<TagNode>,
sub_list: Option<TagNode>,
}
@ -20,11 +20,15 @@ impl ListCtx {
Self { numbering, items: Vec::new() }
}
pub(crate) fn push_label(&mut self, nodes: Vec<TagNode>) {
self.items.push(ListItem { label: nodes, body: None, sub_list: None });
pub(crate) fn push_label(&mut self, contents: GroupContents) {
self.items.push(ListItem {
label: TagNode::group(Tag::Lbl, contents),
body: None,
sub_list: None,
});
}
pub(crate) fn push_body(&mut self, mut nodes: Vec<TagNode>) {
pub(crate) fn push_body(&mut self, mut contents: GroupContents) {
let item = self.items.last_mut().expect("ListItemLabel");
// Nested lists are expected to have the following structure:
@ -60,40 +64,43 @@ impl ListCtx {
// ```
//
// So move the nested list out of the list item.
if let [_, TagNode::Group(TagKind::L(_), _)] = nodes.as_slice() {
item.sub_list = nodes.pop();
if let [_, TagNode::Group(group)] = contents.nodes.as_slice()
&& let TagKind::L(_) = group.tag
{
item.sub_list = contents.nodes.pop();
}
item.body = Some(nodes);
item.body = Some(TagNode::group(Tag::LBody, contents));
}
pub(crate) fn push_bib_entry(&mut self, nodes: Vec<TagNode>) {
let nodes = vec![TagNode::group(Tag::BibEntry, nodes)];
pub(crate) fn push_bib_entry(&mut self, contents: GroupContents) {
let nodes = vec![TagNode::group(Tag::BibEntry, contents)];
// Bibliography lists cannot be nested, but may be missing labels.
let body = TagNode::virtual_group(Tag::LBody, nodes);
if let Some(item) = self.items.last_mut().filter(|item| item.body.is_none()) {
item.body = Some(nodes);
item.body = Some(body);
} else {
self.items.push(ListItem {
label: Vec::new(),
body: Some(nodes),
label: TagNode::empty_group(Tag::Lbl),
body: Some(body),
sub_list: None,
});
}
}
pub(crate) fn build_list(self, mut nodes: Vec<TagNode>) -> TagNode {
pub(crate) fn build_list(self, mut contents: GroupContents) -> TagNode {
for item in self.items.into_iter() {
nodes.push(TagNode::group(
contents.nodes.push(TagNode::virtual_group(
Tag::LI,
vec![
TagNode::group(Tag::Lbl, item.label),
TagNode::group(Tag::LBody, item.body.unwrap_or_default()),
item.label,
item.body.unwrap_or_else(|| TagNode::empty_group(Tag::LBody)),
],
));
if let Some(sub_list) = item.sub_list {
nodes.push(sub_list);
contents.nodes.push(sub_list);
}
}
TagNode::group(Tag::L(self.numbering), nodes)
TagNode::group(Tag::L(self.numbering), contents)
}
}

View File

@ -10,7 +10,7 @@ use krilla::page::Page;
use krilla::surface::Surface;
use krilla::tagging::{
ArtifactType, BBox, ContentTag, Identifier, ListNumbering, Node, SpanTag, Tag,
TagGroup, TagKind, TagTree,
TagKind, TagTree,
};
use typst_library::diag::{SourceResult, bail};
use typst_library::foundations::{Content, LinkMarker, Packed};
@ -23,7 +23,7 @@ use typst_library::model::{
TermsElem,
};
use typst_library::pdf::{ArtifactElem, ArtifactKind, PdfMarkerTag, PdfMarkerTagKind};
use typst_library::text::RawElem;
use typst_library::text::{Lang, RawElem};
use typst_library::visualize::ImageElem;
use typst_syntax::Span;
@ -220,7 +220,9 @@ fn push_stack(
}
}
gc.tags.stack.push(StackEntry { loc, span, kind, nodes: Vec::new() });
gc.tags
.stack
.push(StackEntry { loc, span, lang: None, kind, nodes: Vec::new() });
Ok(())
}
@ -324,6 +326,7 @@ pub(crate) fn handle_end(
broken_entries.push(StackEntry {
loc: entry.loc,
span: entry.span,
lang: None,
kind,
nodes: Vec::new(),
});
@ -341,73 +344,77 @@ pub(crate) fn handle_end(
}
fn pop_stack(gc: &mut GlobalContext, entry: StackEntry) {
// Try to propagate the tag language to the parent tag, or the document.
// If successfull omit the language attribute on this tag.
let lang = entry.lang.and_then(|lang| {
let parent_lang = (gc.tags.stack.last_mut())
.map(|e| &mut e.lang)
.unwrap_or(&mut gc.tags.doc_lang);
if parent_lang.is_none_or(|l| l == lang) {
*parent_lang = Some(lang);
return None;
}
Some(lang)
});
let contents = GroupContents { span: entry.span, lang, nodes: entry.nodes };
let node = match entry.kind {
StackEntryKind::Standard(tag) => TagNode::Group(tag, entry.nodes),
StackEntryKind::Outline(ctx) => ctx.build_outline(entry.nodes),
StackEntryKind::Standard(tag) => TagNode::group(tag, contents),
StackEntryKind::Outline(ctx) => ctx.build_outline(contents),
StackEntryKind::OutlineEntry(outline_entry) => {
let Some((outline_ctx, outline_nodes)) = gc.tags.stack.parent_outline()
else {
// PDF/UA compliance of the structure hierarchy is checked
// elsewhere. While this doesn't make a lot of sense, just
// avoid crashing here.
gc.tags.push(TagNode::group(Tag::TOCI, entry.nodes));
return;
};
// FIXME(accessibility): disallow usage of `outline.entry` outside of `outline`
let (outline_ctx, outline_nodes) = (gc.tags.stack.parent_outline())
.expect("outline entries may only exist within an outline");
outline_ctx.insert(outline_nodes, outline_entry, entry.nodes);
outline_ctx.insert(outline_nodes, outline_entry, contents);
return;
}
StackEntryKind::Table(ctx) => ctx.build_table(entry.nodes),
StackEntryKind::Table(ctx) => ctx.build_table(contents),
StackEntryKind::TableCell(cell) => {
let Some(table_ctx) = gc.tags.stack.parent_table() else {
// PDF/UA compliance of the structure hierarchy is checked
// elsewhere. While this doesn't make a lot of sense, just
// avoid crashing here.
let tag = Tag::TD.with_location(Some(cell.span().into_raw()));
gc.tags.push(TagNode::group(tag, entry.nodes));
return;
};
// FIXME(accessibility): disallow usage of `table.cell` and `grid.cell` outside of table/grid
let table_ctx = (gc.tags.stack.parent_table())
.expect("table cells may only exist within a table");
table_ctx.insert(&cell, entry.nodes);
table_ctx.insert(&cell, contents);
return;
}
StackEntryKind::List(list) => list.build_list(entry.nodes),
StackEntryKind::List(list) => list.build_list(contents),
StackEntryKind::ListItemLabel => {
let list_ctx = gc.tags.stack.parent_list().expect("parent list");
list_ctx.push_label(entry.nodes);
list_ctx.push_label(contents);
return;
}
StackEntryKind::ListItemBody => {
let list_ctx = gc.tags.stack.parent_list().expect("parent list");
list_ctx.push_body(entry.nodes);
list_ctx.push_body(contents);
return;
}
StackEntryKind::BibEntry => {
let list_ctx = gc.tags.stack.parent_list().expect("parent list");
list_ctx.push_bib_entry(entry.nodes);
list_ctx.push_bib_entry(contents);
return;
}
StackEntryKind::Figure(ctx) => {
let tag = Tag::Figure(ctx.alt).with_bbox(ctx.bbox.get());
TagNode::group(tag, entry.nodes)
TagNode::group(tag, contents)
}
StackEntryKind::Formula(ctx) => {
let tag = Tag::Formula(ctx.alt).with_bbox(ctx.bbox.get());
TagNode::group(tag, entry.nodes)
TagNode::group(tag, contents)
}
StackEntryKind::Link(_, link) => {
let alt = link.alt.as_ref().map(EcoString::to_string);
let tag = Tag::Link.with_alt_text(alt);
let mut node = TagNode::group(tag, entry.nodes);
let mut node = TagNode::group(tag, contents);
// Wrap link in reference tag, if it's not a url.
if let Destination::Position(_) | Destination::Location(_) = link.dest {
node = TagNode::group(Tag::Reference, vec![node]);
node = TagNode::virtual_group(Tag::Reference, vec![node]);
}
node
}
StackEntryKind::FootnoteRef(decl_loc) => {
// transparently insert all children.
gc.tags.extend(entry.nodes);
gc.tags.extend(contents.nodes);
let ctx = gc.tags.footnotes.entry(decl_loc).or_insert(FootnoteCtx::new());
@ -421,16 +428,16 @@ fn pop_stack(gc: &mut GlobalContext, entry: StackEntry) {
StackEntryKind::FootnoteEntry(footnote_loc) => {
// Store footnotes separately so they can be inserted directly after
// the footnote reference in the reading order.
let tag = TagNode::group(Tag::Note, entry.nodes);
let tag = TagNode::group(Tag::Note, contents);
let ctx = gc.tags.footnotes.entry(footnote_loc).or_insert(FootnoteCtx::new());
ctx.entry = Some(tag);
return;
}
StackEntryKind::Code(desc) => {
let code = TagNode::group(Tag::Code, entry.nodes);
let code = TagNode::group(Tag::Code, contents);
if desc.is_some() {
let desc = TagNode::group(Tag::Span.with_alt_text(desc), Vec::new());
TagNode::group(Tag::NonStruct, vec![desc, code])
let desc = TagNode::empty_group(Tag::Span.with_alt_text(desc));
TagNode::virtual_group(Tag::NonStruct, vec![desc, code])
} else {
code
}
@ -505,6 +512,8 @@ pub(crate) fn update_bbox(
}
pub(crate) struct Tags {
/// The language of the first text item that has been encountered.
pub(crate) doc_lang: Option<Lang>,
/// The intermediary stack of nested tag groups.
pub(crate) stack: TagStack,
/// A list of placeholders corresponding to a [`TagNode::Placeholder`].
@ -528,6 +537,7 @@ pub(crate) struct Tags {
impl Tags {
pub(crate) fn new() -> Self {
Self {
doc_lang: None,
stack: TagStack::new(),
placeholders: Placeholders(Vec::new()),
footnotes: HashMap::new(),
@ -564,15 +574,37 @@ impl Tags {
TagTree::from(children)
}
/// Try to set the language of a parent tag, or the entire document.
/// If the language couldn't be set and is different from the existing one,
/// this will return `Some`, and the language should be specified on the
/// marked content directly.
pub(crate) fn try_set_lang(&mut self, lang: Lang) -> Option<Lang> {
// Discard languages within artifacts.
if self.in_artifact.is_some() {
return None;
}
if self.doc_lang.is_none_or(|l| l == lang) {
self.doc_lang = Some(lang);
return None;
}
if let Some(last) = self.stack.last_mut()
&& last.lang.is_none_or(|l| l == lang)
{
last.lang = Some(lang);
return None;
}
Some(lang)
}
/// Resolves [`Placeholder`] nodes.
fn resolve_node(&mut self, node: TagNode) -> Node {
match node {
TagNode::Group(tag, nodes) => {
TagNode::Group(TagGroup { tag, nodes }) => {
let children = nodes
.into_iter()
.map(|node| self.resolve_node(node))
.collect::<Vec<_>>();
Node::Group(TagGroup::with_children(tag, children))
Node::Group(krilla::tagging::TagGroup::with_children(tag, children))
}
TagNode::Leaf(identifier) => Node::Leaf(identifier),
TagNode::Placeholder(placeholder) => self.placeholders.take(placeholder),
@ -762,6 +794,7 @@ pub(crate) struct LinkId(u32);
pub(crate) struct StackEntry {
pub(crate) loc: Location,
pub(crate) span: Span,
pub(crate) lang: Option<Lang>,
pub(crate) kind: StackEntryKind,
pub(crate) nodes: Vec<TagNode>,
}
@ -995,7 +1028,7 @@ impl BBoxCtx {
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum TagNode {
Group(TagKind, Vec<TagNode>),
Group(TagGroup),
Leaf(Identifier),
/// Allows inserting a placeholder into the tag tree.
/// Currently used for [`krilla::page::Page::add_tagged_annotation`].
@ -1004,9 +1037,38 @@ pub(crate) enum TagNode {
}
impl TagNode {
pub fn group(tag: impl Into<TagKind>, children: Vec<TagNode>) -> Self {
TagNode::Group(tag.into(), children)
pub fn group(tag: impl Into<TagKind>, contents: GroupContents) -> Self {
let lang = contents.lang.map(|l| l.as_str().to_string());
let tag = tag
.into()
.with_lang(lang)
.with_location(Some(contents.span.into_raw()));
TagNode::Group(TagGroup { tag, nodes: contents.nodes })
}
/// A tag group not directly related to a typst element, generated to
/// accomodate the tag structure.
pub fn virtual_group(tag: impl Into<TagKind>, nodes: Vec<TagNode>) -> Self {
let tag = tag.into();
TagNode::Group(TagGroup { tag, nodes })
}
pub fn empty_group(tag: impl Into<TagKind>) -> Self {
Self::virtual_group(tag, Vec::new())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TagGroup {
tag: TagKind,
nodes: Vec<TagNode>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct GroupContents {
span: Span,
lang: Option<Lang>,
nodes: Vec<TagNode>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]

View File

@ -2,7 +2,7 @@ use krilla::tagging::Tag;
use typst_library::foundations::Packed;
use typst_library::model::OutlineEntry;
use crate::tags::TagNode;
use crate::tags::{GroupContents, TagNode};
#[derive(Clone, Debug)]
pub(crate) struct OutlineCtx {
@ -18,7 +18,7 @@ impl OutlineCtx {
&mut self,
outline_nodes: &mut Vec<TagNode>,
entry: Packed<OutlineEntry>,
nodes: Vec<TagNode>,
contents: GroupContents,
) {
let expected_len = entry.level.get() - 1;
if self.stack.len() < expected_len {
@ -29,7 +29,7 @@ impl OutlineCtx {
}
}
let section_entry = TagNode::group(Tag::TOCI, nodes);
let section_entry = TagNode::group(Tag::TOCI, contents);
self.push(outline_nodes, section_entry);
}
@ -45,11 +45,11 @@ impl OutlineCtx {
}
}
pub(crate) fn build_outline(mut self, mut outline_nodes: Vec<TagNode>) -> TagNode {
pub(crate) fn build_outline(mut self, mut contents: GroupContents) -> TagNode {
while !self.stack.is_empty() {
self.finish_section(&mut outline_nodes);
self.finish_section(&mut contents.nodes);
}
TagNode::group(Tag::TOC, outline_nodes)
TagNode::group(Tag::TOC, contents)
}
}
@ -68,6 +68,6 @@ impl OutlineSection {
}
fn into_tag(self) -> TagNode {
TagNode::group(Tag::TOC, self.entries)
TagNode::virtual_group(Tag::TOC, self.entries)
}
}

View File

@ -7,10 +7,9 @@ use smallvec::SmallVec;
use typst_library::foundations::{Packed, Smart};
use typst_library::model::TableCell;
use typst_library::pdf::{TableCellKind, TableHeaderScope};
use typst_syntax::Span;
use crate::tags::util::PropertyValCopied;
use crate::tags::{BBoxCtx, TableId, TagNode};
use crate::tags::{BBoxCtx, GroupContents, TableId, TagNode};
#[derive(Clone, Debug)]
pub(crate) struct TableCtx {
@ -64,7 +63,7 @@ impl TableCtx {
}
}
pub(crate) fn insert(&mut self, cell: &Packed<TableCell>, nodes: Vec<TagNode>) {
pub(crate) fn insert(&mut self, cell: &Packed<TableCell>, contents: GroupContents) {
let x = cell.x.val().unwrap_or_else(|| unreachable!());
let y = cell.y.val().unwrap_or_else(|| unreachable!());
let rowspan = cell.rowspan.val();
@ -98,16 +97,15 @@ impl TableCtx {
colspan: colspan.try_into().unwrap_or(NonZeroU32::MAX),
kind,
headers: SmallVec::new(),
nodes,
span: cell.span(),
contents,
});
}
pub(crate) fn build_table(mut self, mut nodes: Vec<TagNode>) -> TagNode {
pub(crate) fn build_table(mut self, mut contents: GroupContents) -> TagNode {
// Table layouting ensures that there are no overlapping cells, and that
// any gaps left by the user are filled with empty cells.
if self.rows.is_empty() {
return TagNode::group(Tag::Table.with_summary(self.summary), nodes);
return TagNode::group(Tag::Table.with_summary(self.summary), contents);
}
let height = self.rows.len();
let width = self.rows[0].len();
@ -174,7 +172,7 @@ impl TableCtx {
let cell = cell.into_cell()?;
let rowspan = (cell.rowspan.get() != 1).then_some(cell.rowspan);
let colspan = (cell.colspan.get() != 1).then_some(cell.colspan);
let tag = match cell.unwrap_kind() {
let tag: TagKind = match cell.unwrap_kind() {
TableCellKind::Header(_, scope) => {
let id = table_cell_id(self.id, cell.x, cell.y);
let scope = table_header_scope(scope);
@ -183,25 +181,23 @@ impl TableCtx {
.with_headers(Some(cell.headers))
.with_row_span(rowspan)
.with_col_span(colspan)
.with_location(Some(cell.span.into_raw()))
.into()
}
TableCellKind::Footer | TableCellKind::Data => Tag::TD
.with_headers(Some(cell.headers))
.with_row_span(rowspan)
.with_col_span(colspan)
.with_location(Some(cell.span.into_raw()))
.into(),
};
Some(TagNode::Group(tag, cell.nodes))
Some(TagNode::group(tag, cell.contents))
})
.collect();
let row = TagNode::group(Tag::TR, row_nodes);
let row = TagNode::virtual_group(Tag::TR, row_nodes);
// Push the `TR` tags directly.
if !gen_row_groups {
nodes.push(row);
contents.nodes.push(row);
continue;
}
@ -212,7 +208,8 @@ impl TableCtx {
TableCellKind::Footer => Tag::TFoot.into(),
TableCellKind::Data => Tag::TBody.into(),
};
nodes.push(TagNode::Group(tag, std::mem::take(&mut row_chunk)));
let chunk_nodes = std::mem::take(&mut row_chunk);
contents.nodes.push(TagNode::virtual_group(tag, chunk_nodes));
chunk_kind = row_kind;
}
@ -225,14 +222,11 @@ impl TableCtx {
TableCellKind::Footer => Tag::TFoot.into(),
TableCellKind::Data => Tag::TBody.into(),
};
nodes.push(TagNode::Group(tag, row_chunk));
contents.nodes.push(TagNode::virtual_group(tag, row_chunk));
}
let tag = Tag::Table
.with_summary(self.summary)
.with_bbox(self.bbox.get())
.into();
TagNode::Group(tag, nodes)
let tag = Tag::Table.with_summary(self.summary).with_bbox(self.bbox.get());
TagNode::group(tag, contents)
}
fn resolve_cell_headers<F>(
@ -297,8 +291,7 @@ struct TableCtxCell {
colspan: NonZeroU32,
kind: Smart<TableCellKind>,
headers: SmallVec<[TagId; 1]>,
nodes: Vec<TagNode>,
span: Span,
contents: GroupContents,
}
impl TableCtxCell {

View File

@ -7,7 +7,7 @@ use krilla::tagging::SpanTag;
use krilla::text::GlyphId;
use typst_library::diag::{SourceResult, bail};
use typst_library::layout::Size;
use typst_library::text::{Font, Glyph, TextItem};
use typst_library::text::{Font, Glyph, Lang, TextItem};
use typst_library::visualize::FillRule;
use typst_syntax::Span;
@ -22,11 +22,11 @@ pub(crate) fn handle_text(
surface: &mut Surface,
gc: &mut GlobalContext,
) -> SourceResult<()> {
*gc.languages.entry(t.lang).or_insert(0) += t.glyphs.len();
let lang = gc.tags.try_set_lang(t.lang);
let lang = lang.as_ref().map(Lang::as_str);
tags::update_bbox(gc, fc, || t.bbox());
let mut handle = tags::start_span(gc, surface, SpanTag::empty());
let mut handle = tags::start_span(gc, surface, SpanTag::empty().with_lang(lang));
let surface = handle.surface();
let font = convert_font(gc, t.font.clone())?;

View File

@ -3,6 +3,7 @@
- Content: page=0 mcid=0
- Content: page=0 mcid=1
- Tag: BlockQuote
/Lang: "ar"
/K:
- Content: page=0 mcid=2
- Content: page=0 mcid=3

View File

@ -7,6 +7,7 @@
- Content: page=0 mcid=2
- Content: page=0 mcid=3
- Tag: InlineQuote
/Lang: "ar"
/K:
- Tag: P
/K: