-
Notifications
You must be signed in to change notification settings - Fork 542
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix #4848: Ensure Complete Content Descriptions for Custom HTML Tags in CustomHtmlContentHandler #5614
Fix #4848: Ensure Complete Content Descriptions for Custom HTML Tags in CustomHtmlContentHandler #5614
Changes from all commits
bd4eee4
49b199c
de8ac64
3ee8154
af0a6ec
f8fc270
763364a
20e3971
7d39cec
c53a082
d7802c8
4109e3f
e6055a2
77133f0
36595be
1603776
ef6c298
84871c0
063b87e
93f55c5
2b9eef8
f86bcde
51dc10d
1fa6263
a758bbb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -25,9 +25,17 @@ class CustomHtmlContentHandler private constructor( | |
private var originalContentHandler: ContentHandler? = null | ||
private var currentTrackedTag: TrackedTag? = null | ||
private val currentTrackedCustomTags = ArrayDeque<TrackedCustomTag>() | ||
private val contentDescriptionBuilder = StringBuilder() | ||
private val tagContentDescriptions = mutableMapOf<Int, String>() | ||
private var isInListItem = false | ||
private var pendingNewline = false | ||
private val blockTags = setOf("p", "ol", "ul", "li", "oppia-ul", "oppia-ol", "oppia-li", "div") | ||
|
||
override fun endElement(uri: String?, localName: String?, qName: String?) { | ||
originalContentHandler?.endElement(uri, localName, qName) | ||
if (localName in blockTags) { | ||
isInListItem = false | ||
} | ||
Comment on lines
35
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This could have been: val tagName = qName ?: localName // Fallback to localName if qName is null
if (tagName in blockTags) {
isInListItem = false
} qName (qualified name) is generally more reliable unless your parser enforces namespaces. |
||
currentTrackedTag = null | ||
} | ||
|
||
|
@@ -45,6 +53,11 @@ class CustomHtmlContentHandler private constructor( | |
|
||
override fun characters(ch: CharArray?, start: Int, length: Int) { | ||
originalContentHandler?.characters(ch, start, length) | ||
if (pendingNewline) { | ||
contentDescriptionBuilder.append('\n') | ||
pendingNewline = false | ||
} | ||
ch?.let { contentDescriptionBuilder.append(String(it, start, length)) } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
} | ||
|
||
override fun endDocument() { | ||
|
@@ -56,6 +69,10 @@ class CustomHtmlContentHandler private constructor( | |
// Defer custom tag management to the tag handler so that Android's element parsing takes | ||
// precedence. | ||
currentTrackedTag = TrackedTag(checkNotNull(localName), checkNotNull(atts)) | ||
if (localName in blockTags) { | ||
pendingNewline = true | ||
isInListItem = true | ||
} | ||
originalContentHandler?.startElement(uri, localName, qName, atts) | ||
} | ||
|
||
|
@@ -110,6 +127,14 @@ class CustomHtmlContentHandler private constructor( | |
"Expected tracked tag $currentTrackedTag to match custom tag: $tag" | ||
} | ||
val (_, attributes, openTagIndex) = currentTrackedCustomTag | ||
|
||
val handler = customTagHandlers.getValue(tag) | ||
if (handler is ContentDescriptionProvider) { | ||
val contentDesc = handler.getContentDescription(attributes) | ||
if (contentDesc != null) { | ||
tagContentDescriptions[openTagIndex] = contentDesc | ||
} | ||
} | ||
customTagHandlers.getValue(tag).handleClosingTag(output, indentation = 0, tag) | ||
customTagHandlers.getValue(tag) | ||
.handleTag(attributes, openTagIndex, output.length, output, imageRetriever) | ||
|
@@ -123,7 +148,26 @@ class CustomHtmlContentHandler private constructor( | |
val attributes: Attributes, | ||
val openTagIndex: Int | ||
) | ||
|
||
/** | ||
* Returns the complete content description for the processed HTML, including descriptions | ||
* from all custom tags. | ||
*/ | ||
private fun getContentDescription(): String { | ||
val rawDesc = buildString { | ||
var lastIndex = 0 | ||
tagContentDescriptions.entries.sortedBy { it.key }.forEach { (index, description) -> | ||
if (index > lastIndex) { | ||
append(contentDescriptionBuilder.substring(lastIndex, index)) | ||
} | ||
append(description) | ||
lastIndex = index | ||
} | ||
if (lastIndex < contentDescriptionBuilder.length) { | ||
append(contentDescriptionBuilder.substring(lastIndex)) | ||
} | ||
} | ||
return rawDesc.replace(Regex("\n+"), "\n").trim() | ||
} | ||
/** Handler interface for a custom tag and its attributes. */ | ||
interface CustomTagHandler { | ||
/** | ||
|
@@ -166,6 +210,15 @@ class CustomHtmlContentHandler private constructor( | |
fun handleClosingTag(output: Editable, indentation: Int, tag: String) {} | ||
} | ||
|
||
/** Handler Interface for tag handlers that provide content descriptions. */ | ||
interface ContentDescriptionProvider { | ||
/** | ||
* Returns a content description string for this tag based on its attributes, | ||
* or null if no description is available. | ||
*/ | ||
fun getContentDescription(attributes: Attributes): String? | ||
} | ||
|
||
/** | ||
* Retriever of images for custom tag handlers. The built-in Android analog for this class is | ||
* Html's ImageGetter. | ||
|
@@ -196,6 +249,24 @@ class CustomHtmlContentHandler private constructor( | |
} | ||
|
||
companion object { | ||
/** | ||
* Returns the content description for the HTML content, processing all custom tags that implement | ||
* [ContentDescriptionProvider]. | ||
*/ | ||
fun <T> getContentDescription( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I noticed that while this function was created, it was not used as required by the issue description. The only present usage is in According to #4848, we should ensure descriptions are generated for answers, link cases, including anchors, or any case when text can appear between tags. This means this new function should be used with |
||
html: String, | ||
imageRetriever: T?, | ||
customTagHandlers: Map<String, CustomTagHandler> | ||
): String where T : Html.ImageGetter, T : ImageRetriever { | ||
val handler = CustomHtmlContentHandler(customTagHandlers, imageRetriever) | ||
HtmlCompat.fromHtml( | ||
"<init-custom-handler/>$html", | ||
HtmlCompat.FROM_HTML_MODE_LEGACY, | ||
imageRetriever, | ||
handler | ||
) | ||
Comment on lines
+262
to
+267
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This code appears unused, what does it do? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The code is not unused, It triggers the HTML parsing process, allowing This approach leverages Android’s built-in HTML parsing system, ensuring compatibility with the framework’s expectations, and allows the reuse of the same parsing logic for both content description and display purposes. I'll add comments explaining this and the
|
||
return handler.getContentDescription() | ||
} | ||
/** | ||
* Returns a new [Spannable] with HTML parsed from [html] using the specified [imageRetriever] | ||
* for handling image retrieval, and map of tags to [CustomTagHandler]s for handling custom | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,6 +5,7 @@ import android.text.Editable | |
import android.text.Spannable | ||
import android.text.Spanned | ||
import org.oppia.android.util.locale.OppiaLocale | ||
import org.xml.sax.Attributes | ||
import java.util.Stack | ||
|
||
/** The custom <li> tag corresponding to [LiTagHandler]. */ | ||
|
@@ -23,7 +24,7 @@ const val CUSTOM_LIST_OL_TAG = "oppia-ol" | |
class LiTagHandler( | ||
private val context: Context, | ||
private val displayLocale: OppiaLocale.DisplayLocale | ||
) : CustomHtmlContentHandler.CustomTagHandler { | ||
) : CustomHtmlContentHandler.CustomTagHandler, CustomHtmlContentHandler.ContentDescriptionProvider { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since |
||
private val pendingLists = Stack<ListTag<*, *>>() | ||
private val latestPendingList: ListTag<*, *>? | ||
get() = pendingLists.lastOrNull() | ||
|
@@ -291,4 +292,8 @@ class LiTagHandler( | |
private fun <T : Mark<*>> Spannable.addMark(mark: T) = | ||
setSpan(mark, length, length, Spanned.SPAN_MARK_MARK) | ||
} | ||
|
||
override fun getContentDescription(attributes: Attributes): String { | ||
return "" | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure what pending new line here means, maybe a rename or adding a comment might clear things up. I had a bit of trouble understanding how it is used in
override fun characters
below.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It acts as a flag that indicates whether a newline character should be inserted before the next text content. It's used when dealing with block-level HTML elements